commit d0b1529c3fb469274113c8e6a4a2254f63b323d0
Author: Vojtěch Maršál
Date: Sat Jun 13 14:31:31 2026 +0200
Initial commit: BountyBoard Android client
Native Kotlin + Jetpack Compose (Material 3) client for the BountyBoard
platform. Cookie-session + CSRF auth with a configurable base URL, the full
role-based feature set (admin / consultant / developer), local + OIDC SSO
sign-in, and background push notifications (WorkManager poll) for alerts and
messages. Bundles the app's editorial "ledger" design system and the original
Fraunces / Schibsted Grotesk / Spline Sans Mono typefaces.
Includes a prebuilt debug APK (BountyBoard-debug.apk) for side-loading.
Co-Authored-By: Claude Opus 4.8 (1M context)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..14c2233
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+/app/build
+/app/release
+*.apk
+*.aab
diff --git a/BountyBoard-debug.apk b/BountyBoard-debug.apk
new file mode 100644
index 0000000..d160305
Binary files /dev/null and b/BountyBoard-debug.apk differ
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..94b4b9b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,101 @@
+# Bounty Board — Android
+
+A native Android client for the [Bounty Board](../BountyBoard) consulting work-management
+platform. Built with **Kotlin + Jetpack Compose (Material 3)**, it covers the full
+role-based feature set — admin, consultant, and developer — over the server's
+cookie-session + CSRF API.
+
+The visual identity mirrors the web app's "ledger / bounty-poster" aesthetic: warm
+paper grounds, brown/amber ink, hard offset shadows (no blur), 2px corners, ◈ bounty
+chips, and the original **Fraunces / Schibsted Grotesk / Spline Sans Mono** typefaces
+(bundled, instanced from the web app's variable fonts).
+
+## First run
+
+The app opens on a **Login** page. The default server is
+`https://bountyboard.anypreta.com/`. Expand **Server** on the login screen to point the
+app at any other Bounty Board instance — the base URL is persisted and every request is
+retargeted at runtime. Sign in with email/password, register a developer account, or use
+"Forgot password". A forced password change is handled automatically when the server
+requires it.
+
+## Features
+
+- **Auth** — login (with configurable base URL), self-registration, forgot password,
+ forced password change. Session + CSRF are handled transparently by a persistent
+ cookie jar and a double-submit interceptor.
+- **Developer** — bounty board with search / project / min-bounty / sort filters and
+ claim flow; My Tasks kanban (Assigned · In progress · In review · Done); per-task
+ start / submit / abandon / withdraw; comments; time logging; metrics dashboard;
+ leaderboard.
+- **Consultant** — atomization board (grouped by project, status filters, quick
+ publish); subdivide / extend / edit / publish; reviews queue with an acceptance-
+ criteria checklist; approve / decline claims; assign-to-AI; developer pool management;
+ metrics.
+- **Admin** — users (roles, disable, force-reset, delete); projects/customers wizard
+ (per-system credential fields, test connection, sync now); runtime settings; audit
+ log; live service-status panel.
+- **Shared** — task detail with full role-aware action bar and timeline; real-time-feel
+ messaging (DMs, group/project conversations, image + file attachments, read receipts);
+ notification center with deep links; profile (avatar upload, bio/contact, theme
+ picker with all six themes, leaderboard opt-out, sign-out-everywhere); public profile
+ cards.
+
+## Architecture
+
+```
+data/ AppContainer (manual DI) · Repository · SessionManager · SettingsStore (DataStore)
+ net/ Retrofit ApiService · PersistentCookieJar · CSRF + dynamic-host interceptors · DTOs
+ model/ wire data classes
+ui/
+ theme/ palette (6 themes) · typography · 2px shapes · hard-offset shadow modifier
+ components/ BBButton · BBCard · BountyChip · StatusBadge · BBAvatar · BBTextField · dialogs · TaskCard
+ nav/ AppRoot (bootstrap/auth gate) · MainShell (role drawer + NavHost) · Routes
+ auth/ developer/ consultant/ admin/ task/ messaging/ shared/ profile/ feature screens + ViewModels
+```
+
+- **Networking**: OkHttp + Retrofit (Gson). The base URL is dynamic — a host-selection
+ interceptor rewrites scheme/host/port per request, so changing the server on the login
+ screen takes effect immediately without rebuilding Retrofit. Coil shares the OkHttp
+ stack so `/files/{id}` image loads carry the session cookie.
+- **Auth**: cookie-session only. `bb_session` (httpOnly) and `bb_csrf` are persisted in a
+ `PersistentCookieJar`; mutating requests echo `bb_csrf` into the `X-CSRF-Token` header.
+- **State**: ViewModels expose Compose `mutableStateOf` snapshots; screens are stateless
+ and driven by `LocalAppContainer`.
+
+## Building
+
+Requirements: **JDK 17 or 21** (AGP 8.5 does not support newer JDKs as the build JVM),
+the Android SDK with **platform 34** and **build-tools 34.0.0**, and Gradle 8.9+.
+
+The easiest path is to open the project in **Android Studio** (Koala / 2024.1+), let it
+sync, and Run.
+
+From the CLI (the Gradle wrapper is included; point `JAVA_HOME` at a JDK 17–21):
+
+```bash
+cd BountyBoard_android
+JAVA_HOME=/path/to/jdk-21 ./gradlew assembleDebug # APK → app/build/outputs/apk/debug/
+JAVA_HOME=/path/to/jdk-21 ./gradlew installDebug # install on a connected device/emulator
+```
+
+A prebuilt debug APK is committed at **`BountyBoard-debug.apk`** in the project root —
+`adb install -r BountyBoard-debug.apk` to side-load it directly.
+
+`local.properties` already points `sdk.dir` at `~/Android/Sdk`; adjust if your SDK lives
+elsewhere. The app allows cleartext traffic so you can point it at a local `http://`
+dev server.
+
+### Sign-in
+
+Local email/password **and** OIDC SSO both work. "Sign in with SSO" opens the server's
+OIDC redirect flow in an in-app browser; once the IdP redirects back, the session cookies
+are captured into the app's HTTP stack and you're signed in. If the server has no OIDC
+configured, the app reports that instead of failing silently.
+
+## Notes
+
+- `minSdk 26`, `targetSdk 34`. No proprietary services; the launcher icon, splash, and
+ fonts are all bundled.
+- The server contract is documented in `../BountyBoard/api/openapi.yaml` and
+ `../BountyBoard/specification.md`; this client implements the §6 application API.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..9348c05
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,74 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "com.anypreta.bountyboard"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.anypreta.bountyboard"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+ vectorDrawables { useSupportLibrary = true }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.splashscreen)
+
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.material.icons.extended)
+ implementation(libs.androidx.ui.text.google.fonts)
+ debugImplementation(libs.androidx.ui.tooling)
+
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.work.runtime)
+ implementation(libs.kotlinx.coroutines.android)
+
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.gson)
+ implementation(libs.okhttp)
+ implementation(libs.okhttp.logging)
+ implementation(libs.coil.compose)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..fb73447
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,8 @@
+# Retrofit / Gson models are accessed reflectively.
+-keep class com.anypreta.bountyboard.data.model.** { *; }
+-keepattributes Signature, *Annotation*
+-keepclassmembers,allowobfuscation class * {
+ @com.google.gson.annotations.SerializedName ;
+}
+-dontwarn okhttp3.**
+-dontwarn retrofit2.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..9809cdb
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/anypreta/bountyboard/BountyBoardApp.kt b/app/src/main/java/com/anypreta/bountyboard/BountyBoardApp.kt
new file mode 100644
index 0000000..c31c6c5
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/BountyBoardApp.kt
@@ -0,0 +1,23 @@
+package com.anypreta.bountyboard
+
+import android.app.Application
+import com.anypreta.bountyboard.data.AppContainer
+import com.anypreta.bountyboard.data.SettingsStore
+import com.anypreta.bountyboard.data.push.PushNotifier
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+
+class BountyBoardApp : Application() {
+
+ lateinit var container: AppContainer
+ private set
+
+ override fun onCreate() {
+ super.onCreate()
+ val initialBaseUrl = runBlocking {
+ SettingsStore(this@BountyBoardApp).baseUrl.first()
+ }
+ container = AppContainer(this, initialBaseUrl)
+ PushNotifier.ensureChannels(this)
+ }
+}
diff --git a/app/src/main/java/com/anypreta/bountyboard/MainActivity.kt b/app/src/main/java/com/anypreta/bountyboard/MainActivity.kt
new file mode 100644
index 0000000..be347b4
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/MainActivity.kt
@@ -0,0 +1,60 @@
+package com.anypreta.bountyboard
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.core.content.ContextCompat
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.anypreta.bountyboard.data.push.PushNotifier
+import com.anypreta.bountyboard.di.LocalAppContainer
+import com.anypreta.bountyboard.ui.nav.AppRoot
+import com.anypreta.bountyboard.ui.theme.BBTheme
+import com.anypreta.bountyboard.ui.theme.BountyBoardTheme
+
+class MainActivity : ComponentActivity() {
+
+ private val requestNotifPermission =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen()
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ val container = (application as BountyBoardApp).container
+
+ // Deep link from a tapped system notification.
+ intent?.getStringExtra(PushNotifier.EXTRA_ROUTE)?.let { container.setDeepLink(it) }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ requestNotifPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+
+ setContent {
+ val themeId by container.settings.theme.collectAsStateWithLifecycle(initialValue = "light")
+ CompositionLocalProvider(LocalAppContainer provides container) {
+ BountyBoardTheme(theme = BBTheme.from(themeId)) {
+ AppRoot()
+ }
+ }
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ val container = (application as BountyBoardApp).container
+ intent.getStringExtra(PushNotifier.EXTRA_ROUTE)?.let { container.setDeepLink(it) }
+ }
+}
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/AppContainer.kt b/app/src/main/java/com/anypreta/bountyboard/data/AppContainer.kt
new file mode 100644
index 0000000..3a635c0
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/AppContainer.kt
@@ -0,0 +1,93 @@
+package com.anypreta.bountyboard.data
+
+import android.content.Context
+import coil.ImageLoader
+import com.anypreta.bountyboard.data.net.ApiService
+import com.anypreta.bountyboard.data.net.CsrfInterceptor
+import com.anypreta.bountyboard.data.net.HostSelectionInterceptor
+import com.anypreta.bountyboard.data.net.PersistentCookieJar
+import com.google.gson.Gson
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.concurrent.TimeUnit
+
+/**
+ * Manual dependency container — created once in [com.anypreta.bountyboard.BountyBoardApp].
+ * Holds the network stack, repository, session, and settings. The base URL is dynamic:
+ * [setBaseUrl] retargets every request without rebuilding Retrofit.
+ */
+class AppContainer(
+ private val appContext: Context,
+ initialBaseUrl: String,
+) {
+ val settings = SettingsStore(appContext)
+ val session = SessionManager()
+ val gson: Gson = Gson()
+
+ val cookieJar = PersistentCookieJar(appContext)
+ private val hostInterceptor = HostSelectionInterceptor(initialBaseUrl)
+
+ private val _baseUrl = MutableStateFlow(HostSelectionInterceptor.normalize(initialBaseUrl))
+ val baseUrl: StateFlow = _baseUrl.asStateFlow()
+
+ private val okHttp: OkHttpClient = OkHttpClient.Builder()
+ .cookieJar(cookieJar)
+ .addInterceptor(hostInterceptor)
+ .addInterceptor(CsrfInterceptor(cookieJar))
+ .addInterceptor(
+ HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC },
+ )
+ .connectTimeout(20, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .build()
+
+ private val retrofit: Retrofit = Retrofit.Builder()
+ // Placeholder; the host interceptor rewrites scheme/host/port per request.
+ .baseUrl("http://localhost/")
+ .client(okHttp)
+ .addConverterFactory(GsonConverterFactory.create(gson))
+ .build()
+
+ val api: ApiService = retrofit.create(ApiService::class.java)
+ val repo = Repository(api, gson)
+
+ /** Coil loader sharing the OkHttp stack so /files/{id} fetches carry cookies. */
+ val imageLoader: ImageLoader = ImageLoader.Builder(appContext)
+ .okHttpClient(okHttp)
+ .crossfade(true)
+ .build()
+
+ /** A pending in-app navigation target (set when the user taps a system notification). */
+ val pendingDeepLink = MutableStateFlow(null)
+
+ fun setDeepLink(route: String?) {
+ if (!route.isNullOrBlank()) pendingDeepLink.value = route
+ }
+
+ fun consumeDeepLink() {
+ pendingDeepLink.value = null
+ }
+
+ fun schedulePush() = com.anypreta.bountyboard.data.push.PushScheduler.schedule(appContext)
+
+ fun cancelPush() = com.anypreta.bountyboard.data.push.PushScheduler.cancel(appContext)
+
+ suspend fun clearPushState() = com.anypreta.bountyboard.data.push.PushStateStore(appContext).clear()
+
+ fun setBaseUrl(url: String) {
+ val normalized = HostSelectionInterceptor.normalize(url)
+ hostInterceptor.setBaseUrl(normalized)
+ _baseUrl.value = normalized
+ }
+
+ /** Absolute URL for a stored file/avatar id. */
+ fun fileUrl(fileId: String): String = _baseUrl.value.trimEnd('/') + "/files/" + fileId
+
+ fun currentHost(): String? = hostInterceptor.currentHost()
+}
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/Repository.kt b/app/src/main/java/com/anypreta/bountyboard/data/Repository.kt
new file mode 100644
index 0000000..a4bb14c
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/Repository.kt
@@ -0,0 +1,64 @@
+package com.anypreta.bountyboard.data
+
+import com.anypreta.bountyboard.data.net.ApiErrorEnvelope
+import com.anypreta.bountyboard.data.net.ApiService
+import com.google.gson.Gson
+import retrofit2.Response
+
+/** A failed API call carrying the server's error code + message. */
+class ApiException(val code: String, override val message: String) : Exception(message)
+
+/**
+ * Thin repository over [ApiService]. Every call funnels through [call], which
+ * unwraps the {"error":{code,message}} envelope into an [ApiException] on failure,
+ * so ViewModels can `runCatching { repo.x() }` uniformly.
+ */
+class Repository(
+ val api: ApiService,
+ private val gson: Gson,
+) {
+ /** Run an API call returning a body; throws [ApiException] on non-2xx. */
+ suspend fun call(block: suspend ApiService.() -> Response): T {
+ val resp = api.block()
+ if (resp.isSuccessful) {
+ @Suppress("UNCHECKED_CAST")
+ return (resp.body() ?: Unit) as T
+ }
+ throw toError(resp)
+ }
+
+ /** Run a call we only care about the success of (204s). */
+ suspend fun action(block: suspend ApiService.() -> Response) {
+ val resp = api.block()
+ if (!resp.isSuccessful) throw toError(resp)
+ }
+
+ private fun toError(resp: Response<*>): ApiException {
+ val raw = try {
+ resp.errorBody()?.string()
+ } catch (_: Exception) {
+ null
+ }
+ if (!raw.isNullOrBlank()) {
+ try {
+ val env = gson.fromJson(raw, ApiErrorEnvelope::class.java)
+ env?.error?.let {
+ return ApiException(it.code.ifEmpty { "error" }, it.message.ifEmpty { friendly(resp.code()) })
+ }
+ } catch (_: Exception) {
+ // fall through
+ }
+ }
+ return ApiException("http_${resp.code()}", friendly(resp.code()))
+ }
+
+ private fun friendly(code: Int): String = when (code) {
+ 401 -> "Your session expired. Please sign in again."
+ 403 -> "You don't have permission to do that."
+ 404 -> "Not found."
+ 409 -> "This changed since you loaded it — reload and try again."
+ 429 -> "Too many attempts. Please wait a moment."
+ in 500..599 -> "The server had a problem. Try again shortly."
+ else -> "Request failed ($code)."
+ }
+}
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/SessionManager.kt b/app/src/main/java/com/anypreta/bountyboard/data/SessionManager.kt
new file mode 100644
index 0000000..f056c28
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/SessionManager.kt
@@ -0,0 +1,38 @@
+package com.anypreta.bountyboard.data
+
+import com.anypreta.bountyboard.data.model.User
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Holds the authenticated user + derived auth state for the whole app. */
+class SessionManager {
+
+ private val _user = MutableStateFlow(null)
+ val user: StateFlow = _user.asStateFlow()
+
+ private val _mustChangePassword = MutableStateFlow(false)
+ val mustChangePassword: StateFlow = _mustChangePassword.asStateFlow()
+
+ private val _oidcEnabled = MutableStateFlow(false)
+ val oidcEnabled: StateFlow = _oidcEnabled.asStateFlow()
+
+ fun setSession(user: User, mustChange: Boolean, oidc: Boolean = _oidcEnabled.value) {
+ _user.value = user
+ _mustChangePassword.value = mustChange
+ _oidcEnabled.value = oidc
+ }
+
+ fun setMustChange(value: Boolean) {
+ _mustChangePassword.value = value
+ }
+
+ fun setOidcEnabled(value: Boolean) {
+ _oidcEnabled.value = value
+ }
+
+ fun clear() {
+ _user.value = null
+ _mustChangePassword.value = false
+ }
+}
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/SettingsStore.kt b/app/src/main/java/com/anypreta/bountyboard/data/SettingsStore.kt
new file mode 100644
index 0000000..d329566
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/SettingsStore.kt
@@ -0,0 +1,39 @@
+package com.anypreta.bountyboard.data
+
+import android.content.Context
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.dataStore by preferencesDataStore(name = "bb_settings")
+
+/** Persists the configurable base URL and the selected theme. */
+class SettingsStore(private val context: Context) {
+
+ private val keyBaseUrl = stringPreferencesKey("base_url")
+ private val keyTheme = stringPreferencesKey("theme")
+ private val keyPush = booleanPreferencesKey("push_enabled")
+
+ val baseUrl: Flow = context.dataStore.data.map { it[keyBaseUrl] ?: DEFAULT_BASE_URL }
+ val theme: Flow = context.dataStore.data.map { it[keyTheme] ?: "light" }
+ val pushEnabled: Flow = context.dataStore.data.map { it[keyPush] ?: true }
+
+ suspend fun setBaseUrl(url: String) {
+ context.dataStore.edit { it[keyBaseUrl] = url }
+ }
+
+ suspend fun setTheme(theme: String) {
+ context.dataStore.edit { it[keyTheme] = theme }
+ }
+
+ suspend fun setPushEnabled(enabled: Boolean) {
+ context.dataStore.edit { it[keyPush] = enabled }
+ }
+
+ companion object {
+ const val DEFAULT_BASE_URL = "https://bountyboard.anypreta.com/"
+ }
+}
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/model/Models.kt b/app/src/main/java/com/anypreta/bountyboard/data/model/Models.kt
new file mode 100644
index 0000000..07fb2cb
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/model/Models.kt
@@ -0,0 +1,336 @@
+package com.anypreta.bountyboard.data.model
+
+import com.google.gson.annotations.SerializedName
+
+// ---------------------------------------------------------------------------
+// User
+// ---------------------------------------------------------------------------
+
+data class Roles(
+ val admin: Boolean = false,
+ val consultant: Boolean = false,
+ val developer: Boolean = false,
+)
+
+data class Contact(
+ val phone: String? = null,
+ val location: String? = null,
+ val links: List? = null,
+)
+
+data class NotificationPrefs(
+ val email: Boolean = true,
+ val inApp: Boolean = true,
+)
+
+data class UserSettings(
+ val theme: String? = "light",
+ val navLayout: String? = "side",
+ val notifications: NotificationPrefs? = null,
+ val leaderboardOptOut: Boolean = false,
+)
+
+data class User(
+ val id: String = "",
+ val email: String = "",
+ val name: String = "",
+ val avatarFileId: String? = null,
+ val bio: String? = null,
+ val contact: Contact? = null,
+ val extra: Map? = null,
+ val roles: Roles = Roles(),
+ val settings: UserSettings? = null,
+ val disabled: Boolean = false,
+ val lastSeenAt: String? = null,
+ val createdAt: String? = null,
+ val updatedAt: String? = null,
+ val version: Int = 0,
+) {
+ fun initials(): String {
+ val parts = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
+ return when {
+ parts.isEmpty() -> email.take(1).uppercase()
+ parts.size == 1 -> parts[0].take(2).uppercase()
+ else -> (parts[0].take(1) + parts.last().take(1)).uppercase()
+ }
+ }
+
+ fun roleLabel(): String = buildList {
+ if (roles.admin) add("Admin")
+ if (roles.consultant) add("Consultant")
+ if (roles.developer) add("Developer")
+ }.joinToString(" · ").ifEmpty { "User" }
+}
+
+// ---------------------------------------------------------------------------
+// Customer (== "project")
+// ---------------------------------------------------------------------------
+
+data class Ticketing(
+ val type: String = "demo",
+ val baseUrl: String? = null,
+ val projectKey: String? = null,
+ val pollIntervalSec: Int = 60,
+ val consultantIdentities: Map? = null,
+ val lastSyncAt: String? = null,
+ val lastSyncStatus: String? = null,
+ val lastSyncError: String? = null,
+ val hasCredentials: Boolean = false,
+)
+
+data class Customer(
+ val id: String = "",
+ val name: String = "",
+ val ticketing: Ticketing = Ticketing(),
+ val consultantIds: List? = null,
+ val defaultBudget: Double = 0.0,
+ val archived: Boolean = false,
+ val createdAt: String? = null,
+ val updatedAt: String? = null,
+ val version: Int = 0,
+)
+
+/** Lightweight {id,name} used in board filter dropdowns. */
+data class CustomerRef(
+ val id: String = "",
+ val name: String = "",
+)
+
+// ---------------------------------------------------------------------------
+// Task
+// ---------------------------------------------------------------------------
+
+data class External(
+ val system: String? = null,
+ val key: String? = null,
+ val url: String? = null,
+ val type: String? = null,
+ val orphaned: Boolean = false,
+)
+
+data class Assignee(
+ val kind: String = "human",
+ val userId: String? = null,
+ val jobId: String? = null,
+)
+
+data class ClaimRequest(
+ val developerId: String = "",
+ val note: String? = null,
+ val at: String? = null,
+)
+
+data class TaskAttachment(
+ val name: String? = null,
+ val url: String? = null,
+ val mimeType: String? = null,
+ val fileId: String? = null,
+)
+
+data class TimelineEntry(
+ val at: String? = null,
+ val actorId: String? = null,
+ val event: String? = null,
+ val data: Map? = null,
+)
+
+data class Comment(
+ val id: String = "",
+ val authorId: String = "",
+ val body: String = "",
+ val at: String? = null,
+)
+
+data class TimeLogEntry(
+ val developerId: String = "",
+ val minutes: Int = 0,
+ val note: String? = null,
+ val at: String? = null,
+)
+
+data class Task(
+ val id: String = "",
+ val customerId: String = "",
+ val consultantId: String = "",
+ val origin: String = "imported",
+ val parentId: String? = null,
+ val rootId: String? = null,
+ val external: External? = null,
+ val title: String = "",
+ val description: String = "",
+ val acceptanceCriteria: List? = null,
+ val attachments: List? = null,
+ val links: List? = null,
+ val effortCoefficient: Double = 0.0,
+ val budget: Double = 0.0,
+ val bounty: Double = 0.0,
+ val status: String = "imported",
+ val assignee: Assignee? = null,
+ val claimRequests: List? = null,
+ val atomizationNote: String? = null,
+ val timeline: List? = null,
+ val comments: List? = null,
+ val timeLog: List? = null,
+ val publishedAt: String? = null,
+ val createdAt: String? = null,
+ val updatedAt: String? = null,
+ val version: Int = 0,
+)
+
+// ---------------------------------------------------------------------------
+// Messaging
+// ---------------------------------------------------------------------------
+
+data class ConversationSummary(
+ val id: String = "",
+ val kind: String = "dm",
+ val title: String? = null,
+ val customerId: String? = null,
+ val participantIds: List? = null,
+ val creatorId: String? = null,
+ val lastMessageAt: String? = null,
+ val unread: Int = 0,
+)
+
+data class Conversation(
+ val id: String = "",
+ val kind: String = "dm",
+ val customerId: String? = null,
+ val title: String? = null,
+ val creatorId: String? = null,
+ val participantIds: List? = null,
+ val lastMessageAt: String? = null,
+ val createdAt: String? = null,
+)
+
+data class MessageAttachment(
+ val fileId: String = "",
+ val name: String? = null,
+ val mimeType: String? = null,
+ val size: Long = 0,
+ val isImage: Boolean = false,
+)
+
+data class ReadReceipt(
+ val userId: String = "",
+ val at: String? = null,
+)
+
+data class Message(
+ val id: String = "",
+ val conversationId: String = "",
+ val senderId: String = "",
+ val body: String = "",
+ val attachments: List? = null,
+ val readBy: List? = null,
+ val editedAt: String? = null,
+ val deletedAt: String? = null,
+)
+
+// ---------------------------------------------------------------------------
+// Notifications
+// ---------------------------------------------------------------------------
+
+data class Notification(
+ val id: String = "",
+ val userId: String = "",
+ val kind: String = "",
+ val title: String = "",
+ val body: String = "",
+ val link: String? = null,
+ val readAt: String? = null,
+ val createdAt: String? = null,
+) {
+ val isUnread: Boolean get() = readAt == null
+}
+
+// ---------------------------------------------------------------------------
+// Misc shared
+// ---------------------------------------------------------------------------
+
+data class UserCard(
+ val id: String = "",
+ val name: String = "",
+ val bio: String? = null,
+ val roles: Roles = Roles(),
+ val avatarFileId: String? = null,
+ val contact: Contact? = null,
+ val extra: Map? = null,
+)
+
+data class PoolDeveloper(
+ val id: String = "",
+ val name: String = "",
+ val email: String = "",
+ val avatarFileId: String? = null,
+ val inPool: Boolean = false,
+ val bio: String? = null,
+)
+
+data class LeaderboardEntry(
+ val developerId: String = "",
+ val name: String = "",
+ val avatarFileId: String? = null,
+ val totalBounty: Double = 0.0,
+ val tasksCompleted: Int = 0,
+ val rank: Int = 0,
+)
+
+data class FileUploadResult(
+ val fileId: String = "",
+ val name: String = "",
+ val mimeType: String = "",
+ val size: Long = 0,
+ val isImage: Boolean = false,
+)
+
+data class ServiceHealth(
+ val atomizer: ServiceState? = null,
+ val workPerformer: ServiceState? = null,
+)
+
+data class ServiceState(
+ val healthy: Boolean = false,
+ val breaker: String? = null,
+ val latencyMs: Long = 0,
+ val error: String? = null,
+)
+
+// ---------------------------------------------------------------------------
+// Metrics (flexible — server returns rich aggregations)
+// ---------------------------------------------------------------------------
+
+data class MetricsSeriesPoint(
+ val bucket: String = "",
+ val amount: Double = 0.0,
+ val count: Int = 0,
+)
+
+data class MetricsBreakdown(
+ val key: String = "",
+ val label: String = "",
+ val totalBounty: Double = 0.0,
+ val tasksCompleted: Int = 0,
+)
+
+data class Metrics(
+ val totalBounty: Double = 0.0,
+ val tasksCompleted: Int = 0,
+ val approvalRate: Double = 0.0,
+ val avgLeadTimeHours: Double = 0.0,
+ val timeLoggedMinutes: Long = 0,
+ val boardDepth: Int = 0,
+ val series: List? = null,
+ val byCustomer: List? = null,
+ val byDeveloper: List? = null,
+)
+
+data class AuditEntry(
+ val id: String = "",
+ val at: String? = null,
+ val actorId: String? = null,
+ val action: String = "",
+ val entity: String = "",
+ val entityId: String? = null,
+ val diff: Map? = null,
+)
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/net/ApiService.kt b/app/src/main/java/com/anypreta/bountyboard/data/net/ApiService.kt
new file mode 100644
index 0000000..be2d243
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/net/ApiService.kt
@@ -0,0 +1,276 @@
+package com.anypreta.bountyboard.data.net
+
+import com.google.gson.JsonObject
+import okhttp3.MultipartBody
+import okhttp3.ResponseBody
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.Multipart
+import retrofit2.http.PATCH
+import retrofit2.http.POST
+import retrofit2.http.Part
+import retrofit2.http.Path
+import retrofit2.http.Query
+import retrofit2.http.Streaming
+
+/**
+ * The full BountyBoard HTTP surface (spec §6, api/openapi.yaml). Base path /api/v1.
+ * Auth is via cookie jar; CSRF header is injected by [CsrfInterceptor]. All calls
+ * return [Response] so the repository can read the {"error":{...}} envelope uniformly.
+ */
+interface ApiService {
+
+ // ---- Auth ----
+ @POST("api/v1/auth/login")
+ suspend fun login(@Body body: LoginRequest): Response
+
+ @POST("api/v1/auth/register")
+ suspend fun register(@Body body: RegisterRequest): Response
+
+ @GET("api/v1/auth/me")
+ suspend fun me(): Response
+
+ @POST("api/v1/auth/logout")
+ suspend fun logout(): Response
+
+ @POST("api/v1/auth/logout-all")
+ suspend fun logoutAll(): Response
+
+ @POST("api/v1/auth/change-password")
+ suspend fun changePassword(@Body body: ChangePasswordRequest): Response
+
+ @POST("api/v1/auth/forgot")
+ suspend fun forgot(@Body body: ForgotRequest): Response
+
+ @POST("api/v1/auth/reset")
+ suspend fun reset(@Body body: ResetRequest): Response
+
+ // ---- Developer board ----
+ @GET("api/v1/board")
+ suspend fun board(
+ @Query("customerId") customerId: String? = null,
+ @Query("q") q: String? = null,
+ @Query("minBounty") minBounty: Double? = null,
+ @Query("sort") sort: String? = null,
+ ): Response
+
+ @GET("api/v1/my-tasks")
+ suspend fun myTasks(): Response
+
+ @GET("api/v1/leaderboard")
+ suspend fun leaderboard(): Response
+
+ @GET("api/v1/developer/metrics")
+ suspend fun developerMetrics(
+ @Query("from") from: String? = null,
+ @Query("to") to: String? = null,
+ ): Response
+
+ // ---- Consultant ----
+ @GET("api/v1/consultant/board")
+ suspend fun consultantBoard(
+ @Query("customerId") customerId: String? = null,
+ @Query("status") status: String? = null,
+ ): Response
+
+ @GET("api/v1/consultant/reviews")
+ suspend fun reviews(): Response
+
+ @GET("api/v1/consultant/pool")
+ suspend fun pool(): Response
+
+ @POST("api/v1/consultant/pool")
+ suspend fun addToPool(@Body body: PoolAddBody): Response
+
+ @DELETE("api/v1/consultant/pool/{developerId}")
+ suspend fun removeFromPool(@Path("developerId") developerId: String): Response
+
+ @GET("api/v1/consultant/metrics")
+ suspend fun consultantMetrics(
+ @Query("customerId") customerId: String? = null,
+ @Query("developerId") developerId: String? = null,
+ @Query("from") from: String? = null,
+ @Query("to") to: String? = null,
+ ): Response
+
+ // ---- Tasks ----
+ @GET("api/v1/tasks/{id}")
+ suspend fun task(@Path("id") id: String): Response
+
+ @PATCH("api/v1/tasks/{id}")
+ suspend fun patchTask(@Path("id") id: String, @Body body: TaskPatchBody): Response
+
+ @POST("api/v1/tasks/{id}/subdivide")
+ suspend fun subdivide(@Path("id") id: String, @Body body: SubdivideBody): Response
+
+ @POST("api/v1/tasks/{id}/extend")
+ suspend fun extend(@Path("id") id: String, @Body body: ExtendBody): Response
+
+ @POST("api/v1/tasks/{id}/publish")
+ suspend fun publish(@Path("id") id: String): Response
+
+ @POST("api/v1/tasks/publish")
+ suspend fun publishBulk(@Body body: IdsBody): Response
+
+ @POST("api/v1/tasks/archive")
+ suspend fun archiveBulk(@Body body: IdsBody): Response
+
+ @POST("api/v1/tasks/{id}/archive")
+ suspend fun archiveTask(@Path("id") id: String): Response
+
+ @POST("api/v1/tasks/{id}/claim")
+ suspend fun claim(@Path("id") id: String, @Body body: ClaimBody): Response
+
+ @POST("api/v1/tasks/{id}/claim/withdraw")
+ suspend fun withdrawClaim(@Path("id") id: String): Response
+
+ @POST("api/v1/tasks/{id}/approve-claim")
+ suspend fun approveClaim(@Path("id") id: String, @Body body: ApproveClaimBody): Response
+
+ @POST("api/v1/tasks/{id}/decline-claim")
+ suspend fun declineClaim(@Path("id") id: String, @Body body: DeclineClaimBody): Response
+
+ @POST("api/v1/tasks/{id}/assign-ai")
+ suspend fun assignAi(@Path("id") id: String, @Body body: AssignAiBody): Response
+
+ @POST("api/v1/tasks/{id}/unassign")
+ suspend fun unassign(@Path("id") id: String): Response
+
+ @POST("api/v1/tasks/{id}/start")
+ suspend fun start(@Path("id") id: String): Response
+
+ @POST("api/v1/tasks/{id}/submit-review")
+ suspend fun submitReview(@Path("id") id: String): Response
+
+ @POST("api/v1/tasks/{id}/abandon")
+ suspend fun abandon(@Path("id") id: String): Response
+
+ @POST("api/v1/tasks/{id}/comments")
+ suspend fun addComment(@Path("id") id: String, @Body body: CommentBody): Response
+
+ @POST("api/v1/tasks/{id}/time")
+ suspend fun logTime(@Path("id") id: String, @Body body: TimeBody): Response
+
+ @POST("api/v1/tasks/{id}/review")
+ suspend fun review(@Path("id") id: String, @Body body: ReviewBody): Response
+
+ // ---- Messaging ----
+ @GET("api/v1/conversations")
+ suspend fun conversations(): Response
+
+ @POST("api/v1/conversations")
+ suspend fun createConversation(@Body body: CreateConversationBody): Response
+
+ @GET("api/v1/conversations/{id}/messages")
+ suspend fun messages(
+ @Path("id") id: String,
+ @Query("cursor") cursor: String? = null,
+ @Query("limit") limit: Int? = null,
+ ): Response
+
+ @POST("api/v1/conversations/{id}/messages")
+ suspend fun sendMessage(@Path("id") id: String, @Body body: SendMessageBody): Response
+
+ @POST("api/v1/conversations/{id}/read")
+ suspend fun markConversationRead(@Path("id") id: String): Response
+
+ // ---- Files ----
+ @Multipart
+ @POST("api/v1/files")
+ suspend fun uploadFile(
+ @Part file: MultipartBody.Part,
+ @Part("scope") scope: okhttp3.RequestBody? = null,
+ ): Response
+
+ @Streaming
+ @GET("files/{id}")
+ suspend fun downloadFile(@Path("id") id: String): Response
+
+ // ---- Profile / directory ----
+ @GET("api/v1/profile")
+ suspend fun profile(): Response
+
+ @PATCH("api/v1/profile")
+ suspend fun patchProfile(@Body body: ProfilePatchBody): Response
+
+ @Multipart
+ @POST("api/v1/profile/avatar")
+ suspend fun uploadAvatar(@Part file: MultipartBody.Part): Response
+
+ @GET("api/v1/users")
+ suspend fun users(@Query("q") q: String? = null): Response
+
+ @GET("api/v1/users/{id}/card")
+ suspend fun userCard(@Path("id") id: String): Response
+
+ // ---- Notifications ----
+ @GET("api/v1/notifications")
+ suspend fun notifications(
+ @Query("unread") unread: Boolean? = null,
+ @Query("cursor") cursor: String? = null,
+ @Query("limit") limit: Int? = null,
+ ): Response
+
+ @POST("api/v1/notifications/read")
+ suspend fun markNotificationsRead(@Body body: NotificationsReadBody): Response
+
+ @GET("api/v1/service-health")
+ suspend fun serviceHealth(): Response
+
+ // ---- Admin ----
+ @GET("api/v1/admin/users")
+ suspend fun adminUsers(
+ @Query("q") q: String? = null,
+ @Query("role") role: String? = null,
+ ): Response
+
+ @PATCH("api/v1/admin/users/{id}")
+ suspend fun adminPatchUser(@Path("id") id: String, @Body body: JsonObject): Response
+
+ @DELETE("api/v1/admin/users/{id}")
+ suspend fun adminDeleteUser(@Path("id") id: String): Response
+
+ @GET("api/v1/admin/customers")
+ suspend fun adminCustomers(
+ @Query("includeArchived") includeArchived: Boolean? = null,
+ ): Response
+
+ @POST("api/v1/admin/customers")
+ suspend fun adminCreateCustomer(@Body body: CustomerBody): Response
+
+ @GET("api/v1/admin/customers/{id}")
+ suspend fun adminCustomer(@Path("id") id: String): Response
+
+ @PATCH("api/v1/admin/customers/{id}")
+ suspend fun adminPatchCustomer(@Path("id") id: String, @Body body: CustomerBody): Response
+
+ @DELETE("api/v1/admin/customers/{id}")
+ suspend fun adminDeleteCustomer(@Path("id") id: String): Response
+
+ @POST("api/v1/admin/customers/test-connection")
+ suspend fun adminTestConnectionNew(@Body body: CustomerBody): Response
+
+ @POST("api/v1/admin/customers/{id}/test-connection")
+ suspend fun adminTestConnection(@Path("id") id: String): Response
+
+ @POST("api/v1/admin/customers/{id}/sync-now")
+ suspend fun adminSyncNow(@Path("id") id: String): Response
+
+ @GET("api/v1/admin/settings")
+ suspend fun adminSettings(): Response
+
+ @PATCH("api/v1/admin/settings")
+ suspend fun adminPatchSettings(@Body body: JsonObject): Response
+
+ @GET("api/v1/admin/audit-log")
+ suspend fun adminAuditLog(
+ @Query("actorId") actorId: String? = null,
+ @Query("entityId") entityId: String? = null,
+ @Query("cursor") cursor: String? = null,
+ ): Response
+
+ @GET("api/v1/admin/service-status")
+ suspend fun adminServiceStatus(): Response
+}
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/net/Dtos.kt b/app/src/main/java/com/anypreta/bountyboard/data/net/Dtos.kt
new file mode 100644
index 0000000..540b66c
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/net/Dtos.kt
@@ -0,0 +1,153 @@
+package com.anypreta.bountyboard.data.net
+
+import com.anypreta.bountyboard.data.model.AuditEntry
+import com.anypreta.bountyboard.data.model.Comment
+import com.anypreta.bountyboard.data.model.Conversation
+import com.anypreta.bountyboard.data.model.ConversationSummary
+import com.anypreta.bountyboard.data.model.Customer
+import com.anypreta.bountyboard.data.model.CustomerRef
+import com.anypreta.bountyboard.data.model.LeaderboardEntry
+import com.anypreta.bountyboard.data.model.Message
+import com.anypreta.bountyboard.data.model.Notification
+import com.anypreta.bountyboard.data.model.PoolDeveloper
+import com.anypreta.bountyboard.data.model.Task
+import com.anypreta.bountyboard.data.model.TimeLogEntry
+import com.anypreta.bountyboard.data.model.User
+import com.anypreta.bountyboard.data.model.UserCard
+import com.google.gson.JsonObject
+
+// ---- Auth ----
+data class LoginRequest(val email: String, val password: String)
+data class RegisterRequest(val email: String, val name: String, val password: String)
+data class ChangePasswordRequest(val currentPassword: String, val newPassword: String)
+data class ForgotRequest(val email: String)
+data class ResetRequest(val token: String, val newPassword: String)
+
+data class AuthResponse(
+ val user: User = User(),
+ val mustChangePassword: Boolean = false,
+ val oidcEnabled: Boolean = false,
+)
+
+data class RevokedResponse(val revoked: Int = 0)
+
+// ---- Board / tasks ----
+data class BoardResponse(
+ val tasks: List = emptyList(),
+ val customers: List = emptyList(),
+)
+
+data class MyTasksResponse(val tasks: List = emptyList())
+
+data class ConsultantBoardResponse(
+ val customers: List = emptyList(),
+ val tasks: List = emptyList(),
+)
+
+data class TaskResponse(val task: Task = Task())
+data class TasksResponse(val tasks: List = emptyList())
+data class CommentResponse(val comment: Comment = Comment())
+data class TimeEntryResponse(val entry: TimeLogEntry = TimeLogEntry())
+data class JobResponse(val jobId: String = "")
+data class BulkResult(val published: Int = 0, val archived: Int = 0, val failed: List = emptyList())
+
+// ---- Task mutation bodies ----
+data class ClaimBody(val note: String? = null)
+data class CommentBody(val body: String)
+data class TimeBody(val minutes: Int, val note: String? = null)
+data class ApproveClaimBody(val developerId: String)
+data class DeclineClaimBody(val developerId: String)
+data class ChecklistItem(val criterion: String, val ok: Boolean)
+data class ReviewBody(val decision: String, val note: String? = null, val checklist: List? = null)
+data class SubdivideConstraints(val minTasks: Int? = null, val maxTasks: Int? = null)
+data class SubdivideBody(val note: String? = null, val constraints: SubdivideConstraints? = null, val confirmReplace: Boolean? = null)
+data class ExtendBody(val note: String)
+data class AssignAiContext(val repositoryUrl: String? = null, val branch: String? = null, val instructions: String? = null)
+data class AssignAiBody(val context: AssignAiContext? = null)
+data class IdsBody(val ids: List)
+data class TaskPatchBody(
+ val title: String? = null,
+ val description: String? = null,
+ val acceptanceCriteria: List? = null,
+ val effortCoefficient: Double? = null,
+ val budget: Double? = null,
+ val cascadeBudget: Boolean? = null,
+ val version: Int,
+)
+
+// ---- Messaging ----
+data class ConversationsResponse(val conversations: List = emptyList())
+data class ConversationResponse(val conversation: Conversation = Conversation())
+data class MessagesResponse(val messages: List = emptyList())
+data class MessageResponse(val message: Message = Message())
+data class MarkedResponse(val marked: Int = 0)
+data class CreateConversationBody(
+ val kind: String,
+ val title: String? = null,
+ val customerId: String? = null,
+ val participantIds: List = emptyList(),
+)
+data class SendMessageBody(val body: String, val attachments: List = emptyList())
+
+// ---- Notifications ----
+data class NotificationsResponse(
+ val notifications: List = emptyList(),
+ val unread: Long = 0,
+)
+data class NotificationsReadBody(val ids: List = emptyList())
+
+// ---- Shared / directory ----
+data class UsersResponse(val users: List = emptyList(), val nextCursor: String? = null)
+data class UserResponse(val user: User = User())
+data class CardResponse(val card: UserCard = UserCard())
+data class LeaderboardResponse(
+ val leaderboard: List? = null,
+ val entries: List? = null,
+) {
+ fun items(): List = leaderboard ?: entries ?: emptyList()
+}
+
+// ---- Consultant pool ----
+data class PoolResponse(val developers: List = emptyList())
+data class PoolAddBody(val developerId: String)
+
+// ---- Admin ----
+data class CustomersResponse(val customers: List = emptyList(), val nextCursor: String? = null)
+data class CustomerResponse(val customer: Customer = Customer())
+data class TestConnectionResult(val ok: Boolean = false, val latencyMs: Long = 0, val error: String? = null)
+data class AuditResponse(val entries: List = emptyList(), val nextCursor: String? = null)
+data class CustomerBody(
+ val name: String? = null,
+ val type: String? = null,
+ val baseUrl: String? = null,
+ val projectKey: String? = null,
+ val pollIntervalSec: Int? = null,
+ val defaultBudget: Double? = null,
+ val consultantIds: List? = null,
+ val credentials: Map? = null,
+ val archived: Boolean? = null,
+ val version: Int? = null,
+)
+
+// ---- Profile ----
+data class ProfilePatchBody(
+ val name: String? = null,
+ val bio: String? = null,
+ val contact: com.anypreta.bountyboard.data.model.Contact? = null,
+ val extra: Map? = null,
+ val settings: ProfileSettingsPatch? = null,
+ val version: Int? = null,
+)
+data class ProfileSettingsPatch(
+ val theme: String? = null,
+ val navLayout: String? = null,
+ val leaderboardOptOut: Boolean? = null,
+)
+data class AvatarResponse(val fileId: String = "")
+
+/** Generic error envelope: {"error":{"code","message"}}. */
+data class ApiErrorEnvelope(val error: ApiErrorBody? = null)
+data class ApiErrorBody(val code: String = "", val message: String = "")
+
+/** Loosely-typed payloads (settings, service-status, metrics). */
+typealias JsonMap = JsonObject
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/net/Interceptors.kt b/app/src/main/java/com/anypreta/bountyboard/data/net/Interceptors.kt
new file mode 100644
index 0000000..3bd3f6c
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/net/Interceptors.kt
@@ -0,0 +1,63 @@
+package com.anypreta.bountyboard.data.net
+
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.Interceptor
+import okhttp3.Response
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Holds the user-configurable base URL and rewrites every outgoing request's
+ * scheme/host/port to point at it. Lets the base URL change at runtime (from the
+ * login screen) without rebuilding Retrofit.
+ */
+class HostSelectionInterceptor(initial: String) : Interceptor {
+
+ private val base = AtomicReference(normalize(initial))
+
+ fun setBaseUrl(url: String) {
+ base.set(normalize(url))
+ }
+
+ fun currentHost(): String? = base.get().toHttpUrlOrNull()?.host
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val target = base.get().toHttpUrlOrNull() ?: return chain.proceed(chain.request())
+ val original = chain.request()
+ val newUrl = original.url.newBuilder()
+ .scheme(target.scheme)
+ .host(target.host)
+ .port(target.port)
+ .build()
+ val req = original.newBuilder().url(newUrl).build()
+ return chain.proceed(req)
+ }
+
+ companion object {
+ fun normalize(url: String): String {
+ var u = url.trim()
+ if (u.isEmpty()) return "https://bountyboard.anypreta.com/"
+ if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
+ if (!u.endsWith("/")) u += "/"
+ return u
+ }
+ }
+}
+
+/**
+ * Double-submit CSRF: copy the `bb_csrf` cookie into the `X-CSRF-Token` header on
+ * every mutating request (everything but GET/HEAD/OPTIONS).
+ */
+class CsrfInterceptor(private val cookieJar: PersistentCookieJar) : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val req = chain.request()
+ val method = req.method.uppercase()
+ if (method == "GET" || method == "HEAD" || method == "OPTIONS") {
+ return chain.proceed(req)
+ }
+ val csrf = cookieJar.cookieValue(req.url.host, "bb_csrf")
+ val out = if (csrf != null && req.header("X-CSRF-Token") == null) {
+ req.newBuilder().header("X-CSRF-Token", csrf).build()
+ } else req
+ return chain.proceed(out)
+ }
+}
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/net/PersistentCookieJar.kt b/app/src/main/java/com/anypreta/bountyboard/data/net/PersistentCookieJar.kt
new file mode 100644
index 0000000..9e51da8
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/net/PersistentCookieJar.kt
@@ -0,0 +1,99 @@
+package com.anypreta.bountyboard.data.net
+
+import android.content.Context
+import okhttp3.Cookie
+import okhttp3.CookieJar
+import okhttp3.HttpUrl
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * A cookie jar that persists the session (`bb_session`, httpOnly) and CSRF
+ * (`bb_csrf`) cookies across app restarts in SharedPreferences. This is the
+ * entirety of the auth mechanism — the server is cookie-session based.
+ */
+class PersistentCookieJar(context: Context) : CookieJar {
+
+ private val prefs = context.getSharedPreferences("bb_cookies", Context.MODE_PRIVATE)
+ // host -> (cookie name -> serialized Set-Cookie string)
+ private val store = ConcurrentHashMap>()
+
+ init {
+ prefs.all.forEach { (key, value) ->
+ if (value is String) {
+ val host = key.substringBefore('|')
+ val name = key.substringAfter('|')
+ store.getOrPut(host) { ConcurrentHashMap() }[name] = value
+ }
+ }
+ }
+
+ @Synchronized
+ override fun saveFromResponse(url: HttpUrl, cookies: List) {
+ val bucket = store.getOrPut(url.host) { ConcurrentHashMap() }
+ val editor = prefs.edit()
+ for (cookie in cookies) {
+ val key = "${url.host}|${cookie.name}"
+ if (cookie.expiresAt < System.currentTimeMillis()) {
+ bucket.remove(cookie.name)
+ editor.remove(key)
+ } else {
+ val serialized = cookie.toString()
+ bucket[cookie.name] = serialized
+ editor.putString(key, serialized)
+ }
+ }
+ editor.apply()
+ }
+
+ override fun loadForRequest(url: HttpUrl): List {
+ val bucket = store[url.host] ?: return emptyList()
+ val now = System.currentTimeMillis()
+ return bucket.values.mapNotNull { Cookie.parse(url, it) }
+ .filter { it.expiresAt >= now && it.matches(url) }
+ }
+
+ /**
+ * Import a cookie obtained outside OkHttp (e.g. from the WebView CookieManager after
+ * an OIDC redirect) so the session continues in the app's HTTP stack.
+ */
+ @Synchronized
+ fun putSimpleCookie(host: String, name: String, value: String) {
+ val cookie = Cookie.Builder()
+ .name(name)
+ .value(value)
+ .domain(host)
+ .path("/")
+ .expiresAt(System.currentTimeMillis() + 30L * 24 * 3600 * 1000)
+ .build()
+ val serialized = cookie.toString()
+ store.getOrPut(host) { ConcurrentHashMap() }[name] = serialized
+ prefs.edit().putString("$host|$name", serialized).apply()
+ }
+
+ /** Parse a `k=v; k2=v2` cookie header (CookieManager.getCookie) into the jar. */
+ fun importCookieHeader(host: String, header: String?) {
+ if (header.isNullOrBlank()) return
+ header.split(';').forEach { pair ->
+ val eq = pair.indexOf('=')
+ if (eq > 0) {
+ val name = pair.substring(0, eq).trim()
+ val value = pair.substring(eq + 1).trim()
+ if (name.isNotEmpty() && value.isNotEmpty()) putSimpleCookie(host, name, value)
+ }
+ }
+ }
+
+ /** Read a cookie value (e.g. `bb_csrf`) for the given host. */
+ fun cookieValue(host: String, name: String): String? {
+ val raw = store[host]?.get(name) ?: return null
+ return raw.substringBefore(';').substringAfter('=').takeIf { it.isNotEmpty() }
+ }
+
+ fun hasSession(host: String): Boolean = cookieValue(host, "bb_session") != null
+
+ @Synchronized
+ fun clear() {
+ store.clear()
+ prefs.edit().clear().apply()
+ }
+}
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/push/PushNotifier.kt b/app/src/main/java/com/anypreta/bountyboard/data/push/PushNotifier.kt
new file mode 100644
index 0000000..bf97ae1
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/push/PushNotifier.kt
@@ -0,0 +1,88 @@
+package com.anypreta.bountyboard.data.push
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.anypreta.bountyboard.MainActivity
+import com.anypreta.bountyboard.R
+
+/**
+ * Owns the notification channels and posts system notifications for alerts and
+ * messages, each deep-linking back into the app.
+ */
+object PushNotifier {
+
+ const val CHANNEL_ALERTS = "alerts"
+ const val CHANNEL_MESSAGES = "messages"
+ const val EXTRA_ROUTE = "bb_route"
+
+ fun ensureChannels(context: Context) {
+ val mgr = context.getSystemService(NotificationManager::class.java) ?: return
+ mgr.createNotificationChannel(
+ NotificationChannel(CHANNEL_ALERTS, "Alerts", NotificationManager.IMPORTANCE_HIGH).apply {
+ description = "Claim requests, approvals, reviews, mentions and sync alerts"
+ },
+ )
+ mgr.createNotificationChannel(
+ NotificationChannel(CHANNEL_MESSAGES, "Messages", NotificationManager.IMPORTANCE_HIGH).apply {
+ description = "New direct, group and project messages"
+ },
+ )
+ }
+
+ private fun deepLinkIntent(context: Context, route: String?, requestCode: Int): PendingIntent {
+ val intent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
+ if (route != null) putExtra(EXTRA_ROUTE, route)
+ }
+ return PendingIntent.getActivity(
+ context,
+ requestCode,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ fun notifyAlert(context: Context, id: Int, title: String, body: String, route: String?) {
+ post(context, CHANNEL_ALERTS, id, title, body, route)
+ }
+
+ fun notifyMessage(context: Context, id: Int, title: String, body: String, route: String?) {
+ post(context, CHANNEL_MESSAGES, id, title, body, route)
+ }
+
+ private fun post(context: Context, channel: String, id: Int, title: String, body: String, route: String?) {
+ val nm = NotificationManagerCompat.from(context)
+ if (!nm.areNotificationsEnabled()) return
+ val n = NotificationCompat.Builder(context, channel)
+ .setSmallIcon(R.drawable.ic_stat_diamond)
+ .setContentTitle(title.ifBlank { "Bounty Board" })
+ .setContentText(body)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(body))
+ .setAutoCancel(true)
+ .setContentIntent(deepLinkIntent(context, route, id))
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .build()
+ try {
+ nm.notify(id, n)
+ } catch (_: SecurityException) {
+ // POST_NOTIFICATIONS not granted — silently skip.
+ }
+ }
+
+ /** Map a server-relative notification link to an in-app nav route. */
+ fun routeForServerLink(link: String?): String? {
+ val clean = link?.trim()?.trim('/') ?: return null
+ return when {
+ clean.startsWith("tasks/") -> "task/${clean.removePrefix("tasks/")}"
+ clean.startsWith("conversations/") -> "conversation/${clean.removePrefix("conversations/")}"
+ clean == "board" -> "board"
+ clean.isEmpty() -> "notifications"
+ else -> "notifications"
+ }
+ }
+}
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/push/PushScheduler.kt b/app/src/main/java/com/anypreta/bountyboard/data/push/PushScheduler.kt
new file mode 100644
index 0000000..6a8dadb
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/push/PushScheduler.kt
@@ -0,0 +1,33 @@
+package com.anypreta.bountyboard.data.push
+
+import android.content.Context
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import java.util.concurrent.TimeUnit
+
+/** Schedules (and cancels) the background notification poll. */
+object PushScheduler {
+
+ private const val WORK_NAME = "bb_push_poll"
+
+ /** Enqueue the ~15-minute poll. Idempotent (KEEP) so repeated calls are cheap. */
+ fun schedule(context: Context) {
+ val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES)
+ .setConstraints(
+ Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
+ )
+ .build()
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ WORK_NAME,
+ ExistingPeriodicWorkPolicy.KEEP,
+ request,
+ )
+ }
+
+ fun cancel(context: Context) {
+ WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
+ }
+}
diff --git a/app/src/main/java/com/anypreta/bountyboard/data/push/PushStateStore.kt b/app/src/main/java/com/anypreta/bountyboard/data/push/PushStateStore.kt
new file mode 100644
index 0000000..471237a
--- /dev/null
+++ b/app/src/main/java/com/anypreta/bountyboard/data/push/PushStateStore.kt
@@ -0,0 +1,50 @@
+package com.anypreta.bountyboard.data.push
+
+import android.content.Context
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import kotlinx.coroutines.flow.first
+
+private val Context.pushDataStore by preferencesDataStore(name = "bb_push_state")
+
+/**
+ * Remembers what we've already raised a system notification for, so background
+ * polls only notify on genuinely new alerts/messages (and never spam the entire
+ * backlog on the first run after install/login).
+ */
+class PushStateStore(private val context: Context) {
+
+ private val gson = Gson()
+ private val keySeeded = booleanPreferencesKey("seeded")
+ private val keyLastNotif = stringPreferencesKey("last_notif_id")
+ private val keyConvSeen = stringPreferencesKey("conv_seen") // JSON map id -> lastMessageAt
+
+ suspend fun seeded(): Boolean = context.pushDataStore.data.first()[keySeeded] ?: false
+
+ suspend fun lastNotificationId(): String? = context.pushDataStore.data.first()[keyLastNotif]
+
+ suspend fun convSeen(): Map {
+ val raw = context.pushDataStore.data.first()[keyConvSeen] ?: return emptyMap()
+ return try {
+ gson.fromJson(raw, object : TypeToken
||"), "\n")
+ .replace(Regex("<[^>]+>"), "")
+ .replace("&", "&").replace("<", "<").replace(">", ">")
+ .replace(""", "\"").replace("'", "'").replace(" ", " ")
+ .trim()
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..b5fb3bc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..226f4da
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_splash_diamond.xml b/app/src/main/res/drawable/ic_splash_diamond.xml
new file mode 100644
index 0000000..d2ff636
--- /dev/null
+++ b/app/src/main/res/drawable/ic_splash_diamond.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_stat_diamond.xml b/app/src/main/res/drawable/ic_stat_diamond.xml
new file mode 100644
index 0000000..d33fc36
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stat_diamond.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/font/fraunces_bold.ttf b/app/src/main/res/font/fraunces_bold.ttf
new file mode 100644
index 0000000..3647c4d
Binary files /dev/null and b/app/src/main/res/font/fraunces_bold.ttf differ
diff --git a/app/src/main/res/font/fraunces_regular.ttf b/app/src/main/res/font/fraunces_regular.ttf
new file mode 100644
index 0000000..3351062
Binary files /dev/null and b/app/src/main/res/font/fraunces_regular.ttf differ
diff --git a/app/src/main/res/font/fraunces_semibold.ttf b/app/src/main/res/font/fraunces_semibold.ttf
new file mode 100644
index 0000000..2e50db3
Binary files /dev/null and b/app/src/main/res/font/fraunces_semibold.ttf differ
diff --git a/app/src/main/res/font/schibsted_bold.ttf b/app/src/main/res/font/schibsted_bold.ttf
new file mode 100644
index 0000000..c5a170f
Binary files /dev/null and b/app/src/main/res/font/schibsted_bold.ttf differ
diff --git a/app/src/main/res/font/schibsted_medium.ttf b/app/src/main/res/font/schibsted_medium.ttf
new file mode 100644
index 0000000..9536c86
Binary files /dev/null and b/app/src/main/res/font/schibsted_medium.ttf differ
diff --git a/app/src/main/res/font/schibsted_regular.ttf b/app/src/main/res/font/schibsted_regular.ttf
new file mode 100644
index 0000000..7a4eefc
Binary files /dev/null and b/app/src/main/res/font/schibsted_regular.ttf differ
diff --git a/app/src/main/res/font/spline_mono_bold.ttf b/app/src/main/res/font/spline_mono_bold.ttf
new file mode 100644
index 0000000..4fa536b
Binary files /dev/null and b/app/src/main/res/font/spline_mono_bold.ttf differ
diff --git a/app/src/main/res/font/spline_mono_medium.ttf b/app/src/main/res/font/spline_mono_medium.ttf
new file mode 100644
index 0000000..286a456
Binary files /dev/null and b/app/src/main/res/font/spline_mono_medium.ttf differ
diff --git a/app/src/main/res/font/spline_mono_regular.ttf b/app/src/main/res/font/spline_mono_regular.ttf
new file mode 100644
index 0000000..197066a
Binary files /dev/null and b/app/src/main/res/font/spline_mono_regular.ttf differ
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..b3e26b4
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..b3e26b4
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..abcda1d
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+
+ #FDFBF6
+ #33230E
+ #7A4A14
+ #F2E9D8
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..3fbb6e6
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Bounty Board
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..4883068
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..9deb573
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..82379f9
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..3dc0fba
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,45 @@
+[versions]
+agp = "8.5.2"
+kotlin = "2.0.21"
+coreKtx = "1.13.1"
+lifecycle = "2.8.6"
+activityCompose = "1.9.2"
+composeBom = "2024.09.03"
+navigation = "2.8.2"
+retrofit = "2.11.0"
+okhttp = "4.12.0"
+coil = "2.7.0"
+datastore = "1.1.1"
+coroutines = "1.8.1"
+splashscreen = "1.0.1"
+work = "2.9.1"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-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-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
+androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
+retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
+coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+
+[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" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..2c35211
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..09523c0
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..f5feea6
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 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
+#
+
+##############################################################################
+#
+# Gradle 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 Gradle
+#
+# 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/HEAD/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
+' "$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
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# 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" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ 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, 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" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# 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" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..9b42019
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@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 Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+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
+
+goto fail
+
+: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
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..d312fdd
--- /dev/null
+++ b/settings.gradle.kts
@@ -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 = "BountyBoard"
+include(":app")