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,130 @@
name: release
# På varje push till main: bygg signerad release-APK och lägg den på en
# rullande "latest"-release på släppsidan i Gitea.
#
# Runnern är Pi5 (arm64). Googles aapt2 finns bara för linux x86_64, därför
# används en statiskt byggd arm64-aapt2 (lzhiyong/android-sdk-tools) via
# gradle-flaggan android.aapt2FromMavenOverride. Resten av Android-bygget
# (kotlinc, d8, apksigner) är JVM-baserat och kör fint på arm64.
on:
push:
branches: [main]
workflow_dispatch: {}
env:
CMDLINE_TOOLS_URL: https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
AAPT2_ARM64_URL: https://github.com/lzhiyong/android-sdk-tools/releases/download/35.0.2/android-sdk-tools-static-aarch64.zip
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Skriv signeringsnyckel från secrets
run: |
set -e
if [ -z "${{ secrets.KEYSTORE_B64 }}" ] || [ -z "${{ secrets.KEYSTORE_PASSWORD }}" ]; then
echo "Secrets KEYSTORE_B64 och KEYSTORE_PASSWORD saknas."
echo "Lägg till dem under repo-inställningar -> Actions -> Secrets:"
echo " KEYSTORE_B64 = base64 -w0 signing/fitnessdroid.jks"
echo " KEYSTORE_PASSWORD = storePassword ur signing/keystore.properties"
exit 1
fi
mkdir -p signing
echo "${{ secrets.KEYSTORE_B64 }}" | base64 -d > signing/fitnessdroid.jks
{
echo "storeFile=fitnessdroid.jks"
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}"
echo "keyAlias=fitnessdroid"
echo "keyPassword=${{ secrets.KEYSTORE_PASSWORD }}"
} > signing/keystore.properties
- name: Cache gradle
uses: actions/cache@v4
continue-on-error: true
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: gradle-
- name: Installera JDK 17 + Android SDK (arm64)
run: |
set -e
sudo apt-get update -qq
sudo apt-get install -y -qq openjdk-17-jdk-headless wget unzip jq curl
export ANDROID_HOME=$HOME/android-sdk
mkdir -p "$ANDROID_HOME/cmdline-tools"
wget -q "$CMDLINE_TOOLS_URL" -O /tmp/cmdline-tools.zip
unzip -q /tmp/cmdline-tools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;34.0.0" > /dev/null
# arm64-byggd aapt2 (Googles är x86_64-only)
mkdir -p "$HOME/ci-tools"
wget -q "$AAPT2_ARM64_URL" -O /tmp/sdk-tools-arm64.zip
unzip -qo /tmp/sdk-tools-arm64.zip -d "$HOME/ci-tools"
chmod +x "$HOME/ci-tools/build-tools/aapt2"
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
- name: Bygg signerad release-APK
run: |
set -e
export JAVA_HOME=$(dirname $(dirname $(readlink -f $(which java))))
# Snäll mot Pi5:n — begränsa gradles parallellism
nice -n 10 ./gradlew --no-daemon --console=plain \
-Dorg.gradle.workers.max=2 \
-Pandroid.aapt2FromMavenOverride="$HOME/ci-tools/build-tools/aapt2" \
:app:assembleRelease
mkdir -p build
cp app/build/outputs/apk/release/app-release.apk build/FitnessDroid.apk
(cd build && sha256sum FitnessDroid.apk > checksums.txt && ls -la)
- name: Skapa/uppdatera latest-release + ladda upp APK
env:
API: http://gitea-d:3000/api/v1
REPO: ${{ github.repository }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
TAG="latest"
BASE="https://gitea.brasse-pc.eu/$REPO/releases/download/$TAG"
BODY=$(cat <<EOF
Rullande bygge från senaste main. Commit: ${{ github.sha }}. Byggnummer: ${{ github.run_number }}.
## Installera på telefonen
1. Öppna denna sida i telefonens webbläsare och ladda ner **FitnessDroid.apk**
(eller direktlänk: $BASE/FitnessDroid.apk)
2. Öppna den nedladdade filen och tillåt installation från okänd källa om telefonen frågar.
3. Appen uppdaterar befintlig installation — inget behöver avinstalleras (samma signeringsnyckel).
Kräver Android 12 eller nyare.
EOF
)
rid=$(curl -s -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" '{tag_name:$tag,name:$tag,body:$body}')" | jq -r '.id // empty')
if [ -z "$rid" ]; then
rid=$(curl -s "$API/repos/$REPO/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" | jq -r '.id')
curl -s -X PATCH "$API/repos/$REPO/releases/$rid" \
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "$(jq -n --arg body "$BODY" '{body:$body}')" -o /dev/null
fi
echo "release id: $rid"
for f in FitnessDroid.apk checksums.txt; do
aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \
-H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id")
if [ -n "$aid" ] && [ "$aid" != "null" ]; then
curl -s -X DELETE "$API/repos/$REPO/releases/$rid/assets/$aid" \
-H "Authorization: token $TOKEN" -o /dev/null
fi
curl -s -X POST "$API/repos/$REPO/releases/$rid/assets?name=$f" \
-H "Authorization: token $TOKEN" \
-F "attachment=@build/$f" -o /dev/null -w " $f -> HTTP %{http_code}\n"
done

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
build/
signing/
.gradle/
app/build/
local.properties
.kotlin/
*.iml
.idea/
.DS_Store
captures/
.externalNativeBuild/
.cxx/

26
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,26 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"type": "shell",
"command": "./gradlew :app:assembleRelease && mkdir -p build && cp app/build/outputs/apk/release/app-release.apk build/FitnessDroid.apk && echo 'Klar: build/FitnessDroid.apk'",
"group": { "kind": "build", "isDefault": true },
"problemMatcher": []
},
{
"label": "build (debug)",
"type": "shell",
"command": "./gradlew :app:assembleDebug && mkdir -p build && cp app/build/outputs/apk/debug/app-debug.apk build/FitnessDroid-debug.apk && echo 'Klar: build/FitnessDroid-debug.apk'",
"group": "build",
"problemMatcher": []
},
{
"label": "installera på telefon (adb)",
"type": "shell",
"command": "adb install -r build/FitnessDroid.apk",
"dependsOn": "build",
"problemMatcher": []
}
]
}

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# FitnessDroid
Android-klient för gym-API:t på `https://gymapi.brasse-pc.eu/graphql` — samma
backend som gym-delen i [brasse-pc.eu-v2](https://gitea.brasse-pc.eu/brasse/brasse-pc.eu-v2).
- **Kräver Android 12 eller nyare** (minSdk 31)
- Kotlin + Jetpack Compose + Material 3 (Material You-dynamiska färger)
- GraphQL över OkHttp, tokenlagring i DataStore
## Installera appen på telefonen
Ladda ner `FitnessDroid.apk` från [släppsidan](https://gitea.brasse-pc.eu/brasse/FitnessDroid/releases)
(`latest` byggs om vid varje push till `main`), öppna filen på telefonen och
tillåt installation från okänd källa. Uppdateringar installeras ovanpå — samma
signeringsnyckel används av CI och lokala byggen.
## Bygga själv
### Förutsättningar (Arch/Garuda)
```bash
sudo pacman -S jdk17-openjdk android-tools
```
Android SDK behövs också (platform 35 laddas ner automatiskt av bygget om
licensen är accepterad). Peka ut din SDK i `local.properties` i repo-roten:
```properties
sdk.dir=/home/<du>/Android/Sdk
```
### Bygg
```bash
git clone https://gitea.brasse-pc.eu/brasse/FitnessDroid.git && cd FitnessDroid
./gradlew :app:assembleRelease # signerad release-APK
# APK: app/build/outputs/apk/release/app-release.apk
```
I VS Code: kör build-tasken (`Ctrl+Skift+B`) — den lägger APK:n i
`build/FitnessDroid.apk`. Tasken `installera på telefon (adb)` installerar den
direkt på en ansluten telefon med USB-felsökning på.
### Testa mot API:t
Logga in med ditt vanliga gym-konto (LDAP). API-url:en går att ändra under
"Avancerat" på inloggningsskärmen (standard: `https://gymapi.brasse-pc.eu/graphql`).
## Signeringsnyckeln
Nyckeln är **inte** incheckad (repot är publikt). Lokalt ligger den i
`signing/fitnessdroid.jks` + `signing/keystore.properties` (gitignorerade);
CI får samma nyckel via Actions-secrets `KEYSTORE_B64` och
`KEYSTORE_PASSWORD`. Finns ingen nyckel signeras release-bygget med
debug-nyckeln så att en färsk klon ändå kan bygga — men den APK:n kan inte
uppdatera en installation från släppsidan (annan signatur).
Skapa en ny nyckel (om `signing/` saknas):
```bash
keytool -genkeypair -keystore signing/fitnessdroid.jks -alias fitnessdroid \
-keyalg RSA -keysize 2048 -validity 10950 -dname "CN=FitnessDroid, O=brasse-pc.eu"
# och skriv motsvarande signing/keystore.properties (storeFile/storePassword/keyAlias/keyPassword)
```
## CI
`.gitea/workflows/release.yaml` bygger en signerad APK på Pi5-runnern (arm64)
vid varje push till `main` och lägger den på den rullande `latest`-releasen.
Eftersom Googles aapt2 bara finns för x86_64 använder bygget en statiskt byggd
arm64-aapt2 via `android.aapt2FromMavenOverride`.
Mer om arkitektur och planer: [`doc/plan.md`](doc/plan.md) och
[wikin](https://gitea.brasse-pc.eu/brasse/FitnessDroid/wiki).

84
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,84 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
// Signeringsnyckeln är inte incheckad. Lokalt ligger den i signing/ (gitignorerad),
// i CI skrivs den dit från Actions-secrets. Saknas den signeras release med
// debug-nyckeln så att en färsk klon ändå kan bygga.
val keystorePropsFile = rootProject.file("signing/keystore.properties")
val keystoreProps: Properties? = keystorePropsFile.takeIf { it.exists() }?.let { file ->
Properties().apply { file.inputStream().use { load(it) } }
}
// CI (Gitea Actions) sätter GITHUB_RUN_NUMBER — ger stigande versionCode så
// att telefonen accepterar varje nytt CI-bygge som en uppdatering.
val ciRunNumber = System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull()
android {
namespace = "eu.brassepc.fitnessdroid"
compileSdk = 35
defaultConfig {
applicationId = "eu.brassepc.fitnessdroid"
minSdk = 31
targetSdk = 35
versionCode = ciRunNumber ?: 1
versionName = "0.1.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
}
signingConfigs {
keystoreProps?.let { props ->
create("release") {
storeFile = rootProject.file("signing/${props.getProperty("storeFile")}")
storePassword = props.getProperty("storePassword")
keyAlias = props.getProperty("keyAlias")
keyPassword = props.getProperty("keyPassword")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
signingConfig = if (keystoreProps != null) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
debugImplementation(libs.androidx.compose.ui.tooling)
}

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>

6
build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}

58
doc/plan.md Normal file
View File

@@ -0,0 +1,58 @@
# FitnessDroid — plan
## Mål
En modern Android-app (Android 12+) som pratar med gym-API:t
(`https://gymapi.brasse-pc.eu/graphql`) och på sikt kan allt som webbens
gym-del i brasse-pc.eu-v2 kan: pass, loggning av lyft, statistik, PB,
mallar, grunddata och profil.
## Stack
| Del | Val |
|-----|-----|
| Språk/UI | Kotlin + Jetpack Compose + Material 3 (dynamiska färger) |
| minSdk / target | 31 (Android 12) / 35 |
| Nätverk | OkHttp + kotlinx.serialization, handskrivna GraphQL-queries (samma strängar som webbens `gymTrackerApi.js`) |
| Auth | JWT + refresh-token-rotation + sessions-id, lagras i DataStore, auto-refresh 2 min före utgång |
| Bygge | Gradle 8.9 wrapper, AGP 8.7.3, Kotlin 2.0.21, JDK 17 |
| Signering | `signing/` (gitignorerad) lokalt, Actions-secrets (`KEYSTORE_B64`, `KEYSTORE_PASSWORD`) i CI — samma signatur överallt |
## Leverans
- **VS Code-task `build`** → signerad APK i `build/FitnessDroid.apk`
- **CI (Gitea Actions på Pi5/arm64)** → rullande `latest`-release med
`FitnessDroid.apk` på släppsidan vid varje push till `main`.
arm64-knep: statiskt byggd aapt2 via `android.aapt2FromMavenOverride`.
- versionCode = CI-byggnummer (`GITHUB_RUN_NUMBER`) så uppdateringar alltid räknas uppåt.
## Milstolpar
1. **Grund + login** *(klar i och med första commit)*
Projektskelett som kompilerar, inloggning mot API:t med sessions-
återställning vid appstart, hemskärm som visar profilen.
2. **Aktivt pass** — starta/återuppta pass, lägga till övningar, logga set
(reps/vikt/distans/tid/RPE), vilotimer, passtimer, avsluta pass.
Fälten styrs av övningens tracks-flaggor precis som i webben.
3. **Passhistorik** — lista, visa, redigera och ta bort tidigare pass.
4. **Logga lyft (snabbloggning) + PB** — enkel lyftlogg och PB-matrisen.
5. **Statistik** — perioder (vecka/månad/halvår/år), KPI-kort, trenddiagram,
muskelfördelning, aktivitets-heatmap.
6. **Mallar** — skapa/redigera mallar, starta pass från mall med
auto-progression, spara pass som mall.
7. **Grunddata + profil** — CRUD för muskelgrupper/muskler/övningar,
kroppsvikt.
## Roadmap / möjliga utbyggnader (ej beslutade)
- Offlineläge: logga pass utan nät, synka när nätet är tillbaka
- Notis med pågående pass + vilotimer (foreground service)
- Wear OS-följeslagare / komplikationer
- Widget med veckans volym/streak
- CSV-import som i webben
- R8/minify för mindre APK (nu ~43 MB p.g.a. icons-extended)
## Öppna frågor
- Ska appen på sikt gå över till Apollo Kotlin med schemagenererade typer?
- Behövs stöd för flera konton/servrar?

4
gradle.properties Normal file
View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

34
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,34 @@
[versions]
agp = "8.7.3"
kotlin = "2.0.21"
coreKtx = "1.15.0"
lifecycle = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
navigationCompose = "2.8.5"
okhttp = "4.12.0"
kotlinxSerialization = "1.7.3"
datastore = "1.1.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

82
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

23
settings.gradle.kts Normal file
View File

@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "FitnessDroid"
include(":app")