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) <noreply@anthropic.com>
This commit is contained in:
Vojtěch Maršál
2026-06-13 14:31:31 +02:00
commit d0b1529c3f
88 changed files with 10197 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/build
/app/release
*.apk
*.aab
Binary file not shown.
+101
View File
@@ -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 1721):
```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.
+74
View File
@@ -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)
}
+8
View File
@@ -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 <fields>;
}
-dontwarn okhttp3.**
-dontwarn retrofit2.**
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".BountyBoardApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.BountyBoard.Splash">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.BountyBoard.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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)
}
}
@@ -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) }
}
}
@@ -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<String> = _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<String?>(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()
}
@@ -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 <T> call(block: suspend ApiService.() -> Response<T>): 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<Unit>) {
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)."
}
}
@@ -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<User?>(null)
val user: StateFlow<User?> = _user.asStateFlow()
private val _mustChangePassword = MutableStateFlow(false)
val mustChangePassword: StateFlow<Boolean> = _mustChangePassword.asStateFlow()
private val _oidcEnabled = MutableStateFlow(false)
val oidcEnabled: StateFlow<Boolean> = _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
}
}
@@ -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<String> = context.dataStore.data.map { it[keyBaseUrl] ?: DEFAULT_BASE_URL }
val theme: Flow<String> = context.dataStore.data.map { it[keyTheme] ?: "light" }
val pushEnabled: Flow<Boolean> = 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/"
}
}
@@ -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<String>? = 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<String, Any?>? = 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<String, String>? = 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<String>? = 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<String, Any?>? = 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<String>? = null,
val attachments: List<TaskAttachment>? = null,
val links: List<String>? = 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<ClaimRequest>? = null,
val atomizationNote: String? = null,
val timeline: List<TimelineEntry>? = null,
val comments: List<Comment>? = null,
val timeLog: List<TimeLogEntry>? = 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<String>? = 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<String>? = 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<MessageAttachment>? = null,
val readBy: List<ReadReceipt>? = 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<String, Any?>? = 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<MetricsSeriesPoint>? = null,
val byCustomer: List<MetricsBreakdown>? = null,
val byDeveloper: List<MetricsBreakdown>? = 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<String, Any?>? = null,
)
@@ -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<AuthResponse>
@POST("api/v1/auth/register")
suspend fun register(@Body body: RegisterRequest): Response<AuthResponse>
@GET("api/v1/auth/me")
suspend fun me(): Response<AuthResponse>
@POST("api/v1/auth/logout")
suspend fun logout(): Response<Unit>
@POST("api/v1/auth/logout-all")
suspend fun logoutAll(): Response<RevokedResponse>
@POST("api/v1/auth/change-password")
suspend fun changePassword(@Body body: ChangePasswordRequest): Response<Unit>
@POST("api/v1/auth/forgot")
suspend fun forgot(@Body body: ForgotRequest): Response<JsonObject>
@POST("api/v1/auth/reset")
suspend fun reset(@Body body: ResetRequest): Response<Unit>
// ---- 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<BoardResponse>
@GET("api/v1/my-tasks")
suspend fun myTasks(): Response<MyTasksResponse>
@GET("api/v1/leaderboard")
suspend fun leaderboard(): Response<LeaderboardResponse>
@GET("api/v1/developer/metrics")
suspend fun developerMetrics(
@Query("from") from: String? = null,
@Query("to") to: String? = null,
): Response<JsonObject>
// ---- Consultant ----
@GET("api/v1/consultant/board")
suspend fun consultantBoard(
@Query("customerId") customerId: String? = null,
@Query("status") status: String? = null,
): Response<ConsultantBoardResponse>
@GET("api/v1/consultant/reviews")
suspend fun reviews(): Response<TasksResponse>
@GET("api/v1/consultant/pool")
suspend fun pool(): Response<PoolResponse>
@POST("api/v1/consultant/pool")
suspend fun addToPool(@Body body: PoolAddBody): Response<Unit>
@DELETE("api/v1/consultant/pool/{developerId}")
suspend fun removeFromPool(@Path("developerId") developerId: String): Response<Unit>
@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<JsonObject>
// ---- Tasks ----
@GET("api/v1/tasks/{id}")
suspend fun task(@Path("id") id: String): Response<TaskResponse>
@PATCH("api/v1/tasks/{id}")
suspend fun patchTask(@Path("id") id: String, @Body body: TaskPatchBody): Response<TaskResponse>
@POST("api/v1/tasks/{id}/subdivide")
suspend fun subdivide(@Path("id") id: String, @Body body: SubdivideBody): Response<JobResponse>
@POST("api/v1/tasks/{id}/extend")
suspend fun extend(@Path("id") id: String, @Body body: ExtendBody): Response<JobResponse>
@POST("api/v1/tasks/{id}/publish")
suspend fun publish(@Path("id") id: String): Response<TaskResponse>
@POST("api/v1/tasks/publish")
suspend fun publishBulk(@Body body: IdsBody): Response<BulkResult>
@POST("api/v1/tasks/archive")
suspend fun archiveBulk(@Body body: IdsBody): Response<BulkResult>
@POST("api/v1/tasks/{id}/archive")
suspend fun archiveTask(@Path("id") id: String): Response<Unit>
@POST("api/v1/tasks/{id}/claim")
suspend fun claim(@Path("id") id: String, @Body body: ClaimBody): Response<Unit>
@POST("api/v1/tasks/{id}/claim/withdraw")
suspend fun withdrawClaim(@Path("id") id: String): Response<Unit>
@POST("api/v1/tasks/{id}/approve-claim")
suspend fun approveClaim(@Path("id") id: String, @Body body: ApproveClaimBody): Response<Unit>
@POST("api/v1/tasks/{id}/decline-claim")
suspend fun declineClaim(@Path("id") id: String, @Body body: DeclineClaimBody): Response<Unit>
@POST("api/v1/tasks/{id}/assign-ai")
suspend fun assignAi(@Path("id") id: String, @Body body: AssignAiBody): Response<JobResponse>
@POST("api/v1/tasks/{id}/unassign")
suspend fun unassign(@Path("id") id: String): Response<Unit>
@POST("api/v1/tasks/{id}/start")
suspend fun start(@Path("id") id: String): Response<Unit>
@POST("api/v1/tasks/{id}/submit-review")
suspend fun submitReview(@Path("id") id: String): Response<Unit>
@POST("api/v1/tasks/{id}/abandon")
suspend fun abandon(@Path("id") id: String): Response<Unit>
@POST("api/v1/tasks/{id}/comments")
suspend fun addComment(@Path("id") id: String, @Body body: CommentBody): Response<CommentResponse>
@POST("api/v1/tasks/{id}/time")
suspend fun logTime(@Path("id") id: String, @Body body: TimeBody): Response<TimeEntryResponse>
@POST("api/v1/tasks/{id}/review")
suspend fun review(@Path("id") id: String, @Body body: ReviewBody): Response<Unit>
// ---- Messaging ----
@GET("api/v1/conversations")
suspend fun conversations(): Response<ConversationsResponse>
@POST("api/v1/conversations")
suspend fun createConversation(@Body body: CreateConversationBody): Response<ConversationResponse>
@GET("api/v1/conversations/{id}/messages")
suspend fun messages(
@Path("id") id: String,
@Query("cursor") cursor: String? = null,
@Query("limit") limit: Int? = null,
): Response<MessagesResponse>
@POST("api/v1/conversations/{id}/messages")
suspend fun sendMessage(@Path("id") id: String, @Body body: SendMessageBody): Response<MessageResponse>
@POST("api/v1/conversations/{id}/read")
suspend fun markConversationRead(@Path("id") id: String): Response<MarkedResponse>
// ---- Files ----
@Multipart
@POST("api/v1/files")
suspend fun uploadFile(
@Part file: MultipartBody.Part,
@Part("scope") scope: okhttp3.RequestBody? = null,
): Response<com.anypreta.bountyboard.data.model.FileUploadResult>
@Streaming
@GET("files/{id}")
suspend fun downloadFile(@Path("id") id: String): Response<ResponseBody>
// ---- Profile / directory ----
@GET("api/v1/profile")
suspend fun profile(): Response<UserResponse>
@PATCH("api/v1/profile")
suspend fun patchProfile(@Body body: ProfilePatchBody): Response<UserResponse>
@Multipart
@POST("api/v1/profile/avatar")
suspend fun uploadAvatar(@Part file: MultipartBody.Part): Response<AvatarResponse>
@GET("api/v1/users")
suspend fun users(@Query("q") q: String? = null): Response<UsersResponse>
@GET("api/v1/users/{id}/card")
suspend fun userCard(@Path("id") id: String): Response<CardResponse>
// ---- Notifications ----
@GET("api/v1/notifications")
suspend fun notifications(
@Query("unread") unread: Boolean? = null,
@Query("cursor") cursor: String? = null,
@Query("limit") limit: Int? = null,
): Response<NotificationsResponse>
@POST("api/v1/notifications/read")
suspend fun markNotificationsRead(@Body body: NotificationsReadBody): Response<MarkedResponse>
@GET("api/v1/service-health")
suspend fun serviceHealth(): Response<JsonObject>
// ---- Admin ----
@GET("api/v1/admin/users")
suspend fun adminUsers(
@Query("q") q: String? = null,
@Query("role") role: String? = null,
): Response<UsersResponse>
@PATCH("api/v1/admin/users/{id}")
suspend fun adminPatchUser(@Path("id") id: String, @Body body: JsonObject): Response<UserResponse>
@DELETE("api/v1/admin/users/{id}")
suspend fun adminDeleteUser(@Path("id") id: String): Response<Unit>
@GET("api/v1/admin/customers")
suspend fun adminCustomers(
@Query("includeArchived") includeArchived: Boolean? = null,
): Response<CustomersResponse>
@POST("api/v1/admin/customers")
suspend fun adminCreateCustomer(@Body body: CustomerBody): Response<CustomerResponse>
@GET("api/v1/admin/customers/{id}")
suspend fun adminCustomer(@Path("id") id: String): Response<CustomerResponse>
@PATCH("api/v1/admin/customers/{id}")
suspend fun adminPatchCustomer(@Path("id") id: String, @Body body: CustomerBody): Response<CustomerResponse>
@DELETE("api/v1/admin/customers/{id}")
suspend fun adminDeleteCustomer(@Path("id") id: String): Response<Unit>
@POST("api/v1/admin/customers/test-connection")
suspend fun adminTestConnectionNew(@Body body: CustomerBody): Response<TestConnectionResult>
@POST("api/v1/admin/customers/{id}/test-connection")
suspend fun adminTestConnection(@Path("id") id: String): Response<TestConnectionResult>
@POST("api/v1/admin/customers/{id}/sync-now")
suspend fun adminSyncNow(@Path("id") id: String): Response<Unit>
@GET("api/v1/admin/settings")
suspend fun adminSettings(): Response<JsonObject>
@PATCH("api/v1/admin/settings")
suspend fun adminPatchSettings(@Body body: JsonObject): Response<JsonObject>
@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<AuditResponse>
@GET("api/v1/admin/service-status")
suspend fun adminServiceStatus(): Response<JsonObject>
}
@@ -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<Task> = emptyList(),
val customers: List<CustomerRef> = emptyList(),
)
data class MyTasksResponse(val tasks: List<Task> = emptyList())
data class ConsultantBoardResponse(
val customers: List<Customer> = emptyList(),
val tasks: List<Task> = emptyList(),
)
data class TaskResponse(val task: Task = Task())
data class TasksResponse(val tasks: List<Task> = 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<String> = 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<ChecklistItem>? = 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<String>)
data class TaskPatchBody(
val title: String? = null,
val description: String? = null,
val acceptanceCriteria: List<String>? = null,
val effortCoefficient: Double? = null,
val budget: Double? = null,
val cascadeBudget: Boolean? = null,
val version: Int,
)
// ---- Messaging ----
data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
data class ConversationResponse(val conversation: Conversation = Conversation())
data class MessagesResponse(val messages: List<Message> = 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<String> = emptyList(),
)
data class SendMessageBody(val body: String, val attachments: List<String> = emptyList())
// ---- Notifications ----
data class NotificationsResponse(
val notifications: List<Notification> = emptyList(),
val unread: Long = 0,
)
data class NotificationsReadBody(val ids: List<String> = emptyList())
// ---- Shared / directory ----
data class UsersResponse(val users: List<User> = 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<LeaderboardEntry>? = null,
val entries: List<LeaderboardEntry>? = null,
) {
fun items(): List<LeaderboardEntry> = leaderboard ?: entries ?: emptyList()
}
// ---- Consultant pool ----
data class PoolResponse(val developers: List<PoolDeveloper> = emptyList())
data class PoolAddBody(val developerId: String)
// ---- Admin ----
data class CustomersResponse(val customers: List<Customer> = 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<AuditEntry> = 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<String>? = null,
val credentials: Map<String, String>? = 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<String, Any?>? = 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
@@ -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)
}
}
@@ -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<String, MutableMap<String, String>>()
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<Cookie>) {
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<Cookie> {
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()
}
}
@@ -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"
}
}
}
@@ -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<PushWorker>(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)
}
}
@@ -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<String, String> {
val raw = context.pushDataStore.data.first()[keyConvSeen] ?: return emptyMap()
return try {
gson.fromJson(raw, object : TypeToken<Map<String, String>>() {}.type) ?: emptyMap()
} catch (_: Exception) {
emptyMap()
}
}
suspend fun update(seeded: Boolean, lastNotificationId: String?, convSeen: Map<String, String>) {
context.pushDataStore.edit {
it[keySeeded] = seeded
if (lastNotificationId != null) it[keyLastNotif] = lastNotificationId
it[keyConvSeen] = gson.toJson(convSeen)
}
}
suspend fun clear() {
context.pushDataStore.edit { it.clear() }
}
}
@@ -0,0 +1,93 @@
package com.anypreta.bountyboard.data.push
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.anypreta.bountyboard.BountyBoardApp
import com.anypreta.bountyboard.ui.util.htmlToPlain
import com.anypreta.bountyboard.ui.util.parseInstant
import kotlinx.coroutines.flow.first
/**
* Periodic background poll: fetches unread notifications + conversations and raises
* a system notification for anything new since the last run. Runs in the app process
* even when the UI is closed; auth rides on the persisted cookie jar.
*/
class PushWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val app = applicationContext as? BountyBoardApp ?: return Result.success()
val container = app.container
val state = PushStateStore(applicationContext)
// Respect the user toggle and an existing login.
if (!container.settings.pushEnabled.first()) return Result.success()
val host = container.currentHost() ?: return Result.success()
if (!container.cookieJar.hasSession(host)) return Result.success()
val seeded = state.seeded()
var lastNotifId = state.lastNotificationId()
val convSeen = state.convSeen().toMutableMap()
// ---- Alerts ----
try {
val res = container.repo.call { notifications(unread = true, limit = 25) }
val unread = res.notifications.filter { it.isUnread }
// Newest first from the server; notify oldest→newest for sensible ordering.
val fresh = unread
.filter { lastNotifId == null || it.id > lastNotifId!! }
.sortedBy { it.id }
if (seeded) {
fresh.forEach { n ->
PushNotifier.notifyAlert(
applicationContext,
id = ("a" + n.id).hashCode(),
title = n.title.ifBlank { "New alert" },
body = htmlToPlain(n.body).ifBlank { "You have a new alert." },
route = PushNotifier.routeForServerLink(n.link),
)
}
}
unread.maxByOrNull { it.id }?.let { newest ->
if (lastNotifId == null || newest.id > lastNotifId!!) lastNotifId = newest.id
}
} catch (_: Exception) {
// network/auth hiccup — try again next cycle
}
// ---- Messages ----
try {
val res = container.repo.call { conversations() }
res.conversations.forEach { c ->
val last = c.lastMessageAt
val prev = convSeen[c.id]
val isNew = last != null && (prev == null || isAfter(last, prev))
if (seeded && c.unread > 0 && isNew) {
val title = c.title?.ifBlank { null } ?: "New message"
val body = if (c.unread == 1) "New message" else "${c.unread} new messages"
PushNotifier.notifyMessage(
applicationContext,
id = ("m" + c.id).hashCode(),
title = title,
body = body,
route = "conversation/${c.id}",
)
}
if (last != null) convSeen[c.id] = last
}
} catch (_: Exception) {
}
state.update(seeded = true, lastNotificationId = lastNotifId, convSeen = convSeen)
return Result.success()
}
private fun isAfter(a: String, b: String): Boolean {
val da = parseInstant(a)
val db = parseInstant(b)
return if (da != null && db != null) da.after(db) else a > b
}
}
@@ -0,0 +1,27 @@
package com.anypreta.bountyboard.di
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.anypreta.bountyboard.data.AppContainer
/** The app's DI container, provided at the Compose root. */
val LocalAppContainer = staticCompositionLocalOf<AppContainer> {
error("AppContainer not provided")
}
/**
* Build a ViewModelProvider.Factory that injects the [AppContainer] into a ViewModel.
* Usage: `viewModel(factory = containerVMFactory(container) { MyVm(it) })`.
*/
inline fun <reified VM : ViewModel> containerVMFactory(
container: AppContainer,
crossinline create: (AppContainer) -> VM,
) = viewModelFactory {
initializer { create(container) }
}
@Suppress("UNUSED_PARAMETER")
fun unusedExtras(extras: CreationExtras) = Unit
@@ -0,0 +1,166 @@
package com.anypreta.bountyboard.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.History
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
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.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.AuditEntry
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.Hairline
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.SectionLabel
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.theme.SplineMono
import com.anypreta.bountyboard.ui.util.relativeTime
import kotlinx.coroutines.launch
class AdminAuditViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var entries by mutableStateOf<List<AuditEntry>>(emptyList())
private set
var nextCursor by mutableStateOf<String?>(null)
private set
var loadingMore by mutableStateOf(false)
private set
init {
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
val r = container.repo.call { adminAuditLog() }
entries = r.entries
nextCursor = r.nextCursor
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
fun loadMore() {
val cursor = nextCursor ?: return
if (loadingMore) return
loadingMore = true
viewModelScope.launch {
try {
val r = container.repo.call { adminAuditLog(cursor = cursor) }
entries = entries + r.entries
nextCursor = r.nextCursor
} catch (_: Exception) {
// keep prior entries; surface nothing destructive
} finally {
loadingMore = false
}
}
}
}
@Composable
fun AdminAuditScreen() {
val container = LocalAppContainer.current
val vm: AdminAuditViewModel = viewModel(factory = containerVMFactory(container) { AdminAuditViewModel(it) })
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
if (vm.entries.isEmpty()) {
EmptyState(
title = "No audit entries",
subtitle = "Administrative actions appear here.",
icon = Icons.Outlined.History,
)
} else {
val expanded = remember { mutableStateMapOf<String, Boolean>() }
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(vm.entries, key = { it.id }) { entry ->
AuditCard(
entry = entry,
expanded = expanded[entry.id] == true,
onToggle = { expanded[entry.id] = !(expanded[entry.id] ?: false) },
)
}
if (vm.nextCursor != null) {
item(key = "load_more") {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
BBButton(
"Load more",
onClick = { vm.loadMore() },
style = BBButtonStyle.Default,
loading = vm.loadingMore,
small = true,
)
}
}
}
}
}
}
}
@Composable
private fun AuditCard(entry: AuditEntry, expanded: Boolean, onToggle: () -> Unit) {
val p = BB.colors
BBCard(modifier = Modifier.fillMaxWidth(), onClick = onToggle) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(entry.action, style = MaterialTheme.typography.titleSmall, color = p.text)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SectionLabel(buildString {
append(entry.entity)
entry.entityId?.let { append(" · "); append(it) }
})
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(entry.actorId ?: "system", fontFamily = SplineMono, style = MaterialTheme.typography.labelSmall, color = p.muted)
Text(relativeTime(entry.at), style = MaterialTheme.typography.labelSmall, color = p.muted)
}
if (expanded && !entry.diff.isNullOrEmpty()) {
Hairline()
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
entry.diff!!.entries.forEach { (k, v) ->
Text(
"$k${v?.toString() ?: "null"}",
fontFamily = SplineMono,
style = MaterialTheme.typography.bodySmall,
color = p.text,
)
}
}
}
}
}
}
@@ -0,0 +1,246 @@
package com.anypreta.bountyboard.ui.admin
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Business
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.draw.clip
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.Customer
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBBadge
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.ConfirmDialog
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.FilterPill
import com.anypreta.bountyboard.ui.components.MessageBox
import com.anypreta.bountyboard.ui.components.MessageKind
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.theme.SplineMono
import com.anypreta.bountyboard.ui.util.relativeTime
import kotlinx.coroutines.launch
class AdminCustomersViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var customers by mutableStateOf<List<Customer>>(emptyList())
private set
var actionError by mutableStateOf<String?>(null)
private set
var actionInfo by mutableStateOf<String?>(null)
private set
var includeArchived by mutableStateOf(false)
private set
init {
load()
}
fun toggleArchived() {
includeArchived = !includeArchived; load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
val r = container.repo.call { adminCustomers(includeArchived = if (includeArchived) true else null) }
customers = r.customers
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
fun syncNow(id: String) {
actionError = null; actionInfo = null
viewModelScope.launch {
try {
container.repo.action { adminSyncNow(id) }
actionInfo = "Sync queued"
} catch (e: Exception) {
actionError = e.message
}
}
}
fun delete(id: String) {
actionError = null; actionInfo = null
viewModelScope.launch {
try {
container.repo.action { adminDeleteCustomer(id) }
load()
} catch (e: Exception) {
actionError = e.message
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AdminCustomersScreen(onEditCustomer: (String) -> Unit) {
val container = LocalAppContainer.current
val vm: AdminCustomersViewModel = viewModel(factory = containerVMFactory(container) { AdminCustomersViewModel(it) })
val p = BB.colors
var deleteFor by remember { mutableStateOf<Customer?>(null) }
Column(Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
BBButton("New project", onClick = { onEditCustomer("new") }, style = BBButtonStyle.Primary, small = true, leadingIcon = Icons.Outlined.Add)
FilterPill(
text = if (vm.includeArchived) "Hiding none" else "Active only",
onClick = { vm.toggleArchived() },
showCaret = false,
)
}
vm.actionError?.let {
Box(Modifier.padding(horizontal = 16.dp)) { MessageBox(it, MessageKind.Error) }
}
vm.actionInfo?.let {
Box(Modifier.padding(horizontal = 16.dp)) { MessageBox(it, MessageKind.Info) }
}
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
if (vm.customers.isEmpty()) {
EmptyState(
title = "No projects yet",
subtitle = "Create a project to connect a ticketing source.",
icon = Icons.Outlined.Business,
action = { BBButton("New project", onClick = { onEditCustomer("new") }, style = BBButtonStyle.Primary, small = true) },
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
items(vm.customers, key = { it.id }) { customer ->
CustomerCard(
customer = customer,
onEdit = { onEditCustomer(customer.id) },
onSync = { vm.syncNow(customer.id) },
onDelete = { deleteFor = customer },
)
}
}
}
}
}
deleteFor?.let { c ->
ConfirmDialog(
title = "Delete project",
message = "Delete ${c.name}? Imported tasks for this project may be affected.",
confirmText = "Delete",
danger = true,
onConfirm = { deleteFor = null; vm.delete(c.id) },
onDismiss = { deleteFor = null },
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CustomerCard(
customer: Customer,
onEdit: () -> Unit,
onSync: () -> Unit,
onDelete: () -> Unit,
) {
val p = BB.colors
val t = customer.ticketing
BBCard(modifier = Modifier.fillMaxWidth(), topStrip = true) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(
customer.name.ifBlank { "(unnamed)" },
style = MaterialTheme.typography.titleLarge,
color = p.text,
modifier = Modifier.weight(1f),
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
BBBadge(t.type, color = p.accent)
if (customer.archived) BBBadge("Archived", color = p.err)
}
}
t.projectKey?.takeIf { it.isNotBlank() }?.let {
Text(it, fontFamily = SplineMono, style = MaterialTheme.typography.labelSmall, color = p.muted)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
when (t.lastSyncStatus) {
"ok" -> {
Dot(p.ok)
Text("Synced ${relativeTime(t.lastSyncAt)}".trim(), style = MaterialTheme.typography.bodySmall, color = p.muted)
}
"error" -> {
Dot(p.err)
Text(t.lastSyncError ?: "Sync error", style = MaterialTheme.typography.bodySmall, color = p.err, maxLines = 2)
}
else -> {
Dot(p.muted)
Text("Not synced", style = MaterialTheme.typography.bodySmall, color = p.muted)
}
}
}
if (!t.hasCredentials && t.type != "demo") {
BBBadge("No credentials", color = p.warn)
}
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
BBButton("Edit", onClick = onEdit, style = BBButtonStyle.Default, small = true)
BBButton("Sync now", onClick = onSync, style = BBButtonStyle.Default, small = true)
BBButton("Delete", onClick = onDelete, style = BBButtonStyle.Danger, small = true)
}
}
}
}
@Composable
private fun Dot(color: androidx.compose.ui.graphics.Color) {
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
}
@@ -0,0 +1,217 @@
package com.anypreta.bountyboard.ui.admin
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBBadge
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.Hairline
import com.anypreta.bountyboard.ui.components.ScreenColumn
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.SectionLabel
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.theme.Fraunces
import com.anypreta.bountyboard.ui.theme.SplineMono
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import kotlinx.coroutines.launch
class AdminServiceStatusViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var status by mutableStateOf<JsonObject?>(null)
private set
init {
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
status = container.repo.call { adminServiceStatus() }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
}
private fun JsonObject.bool(key: String): Boolean? =
get(key)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean }?.asBoolean
private fun JsonObject.str(key: String): String? =
get(key)?.takeIf { it.isJsonPrimitive }?.asString
private fun JsonObject.num(key: String): String? =
get(key)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }?.asString
@Composable
fun AdminServiceStatusScreen() {
val container = LocalAppContainer.current
val vm: AdminServiceStatusViewModel = viewModel(factory = containerVMFactory(container) { AdminServiceStatusViewModel(it) })
val p = BB.colors
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
val status = vm.status ?: return@ScreenState
ScreenColumn {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Service status", style = MaterialTheme.typography.headlineMedium, color = p.text)
BBButton("Refresh", onClick = { vm.load() }, style = BBButtonStyle.Default, small = true, leadingIcon = Icons.Outlined.Refresh)
}
// Known services
val handledKeys = mutableSetOf<String>()
listOf("atomizer", "workPerformer", "work_performer").forEach { key ->
val el = status.get(key)
if (el != null && el.isJsonObject) {
handledKeys += key
ServiceCard(name = key, obj = el.asJsonObject)
}
}
// Workers
listOf("syncWorkers", "workers").forEach { key ->
val el = status.get(key)
if (el != null && el.isJsonArray) {
handledKeys += key
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionLabel(key)
el.asJsonArray.forEachIndexed { i, w ->
if (i > 0) Hairline()
if (w.isJsonObject) {
val wo = w.asJsonObject
val healthy = wo.bool("healthy")
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (healthy != null) StatusDot(if (healthy) p.ok else p.err)
Text(
wo.str("id") ?: wo.str("name") ?: "worker ${i + 1}",
style = MaterialTheme.typography.bodyMedium,
color = p.text,
)
wo.str("state")?.let { Text(it, fontFamily = SplineMono, style = MaterialTheme.typography.labelSmall, color = p.muted) }
}
} else {
Text(w.toString(), fontFamily = SplineMono, style = MaterialTheme.typography.bodySmall, color = p.muted)
}
}
}
}
}
}
// Queues
listOf("queues", "jobQueue", "queueDepth").forEach { key ->
val el = status.get(key)
if (el != null) {
handledKeys += key
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionLabel(key)
when {
el.isJsonObject -> el.asJsonObject.entrySet().forEach { (k, v) ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(k, style = MaterialTheme.typography.bodyMedium, color = p.muted)
Text(v.toString(), fontFamily = SplineMono, style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
el.isJsonPrimitive -> Text("Depth: ${el.asString}", fontFamily = SplineMono, style = MaterialTheme.typography.bodyMedium, color = p.text)
else -> Text(el.toString(), fontFamily = SplineMono, style = MaterialTheme.typography.bodySmall, color = p.muted)
}
}
}
}
}
// Raw — anything not yet handled
val rest = status.entrySet().filter { it.key !in handledKeys }
if (rest.isNotEmpty()) {
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionLabel("Raw")
rest.forEachIndexed { i, (k, v) ->
if (i > 0) Hairline()
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(k, style = MaterialTheme.typography.titleSmall, color = p.text)
Text(rawValue(v), fontFamily = SplineMono, style = MaterialTheme.typography.bodySmall, color = p.muted)
}
}
}
}
}
}
}
}
private fun rawValue(el: JsonElement): String = when {
el.isJsonPrimitive -> el.asString
else -> el.toString()
}
@Composable
private fun ServiceCard(name: String, obj: JsonObject) {
val p = BB.colors
val healthy = obj.bool("healthy")
BBCard(modifier = Modifier.fillMaxWidth(), topStrip = true) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatusDot(if (healthy == true) p.ok else p.err)
Text(name, fontFamily = Fraunces, style = MaterialTheme.typography.titleLarge, color = p.text)
healthy?.let { BBBadge(if (it) "Healthy" else "Down", color = if (it) p.ok else p.err) }
}
obj.str("breaker")?.let {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Breaker", style = MaterialTheme.typography.bodyMedium, color = p.muted)
Text(it, fontFamily = SplineMono, style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
obj.num("latencyMs")?.let {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Latency", style = MaterialTheme.typography.bodyMedium, color = p.muted)
Text("${it}ms", fontFamily = SplineMono, style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
obj.str("error")?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = p.err)
}
}
}
}
@Composable
private fun StatusDot(color: Color) {
Box(Modifier.size(10.dp).clip(CircleShape).background(color))
}
@@ -0,0 +1,202 @@
package com.anypreta.bountyboard.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
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.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.BBTextField
import com.anypreta.bountyboard.ui.components.Hairline
import com.anypreta.bountyboard.ui.components.MessageBox
import com.anypreta.bountyboard.ui.components.MessageKind
import com.anypreta.bountyboard.ui.components.ScreenColumn
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.SectionLabel
import com.anypreta.bountyboard.ui.theme.BB
import com.google.gson.JsonObject
import kotlinx.coroutines.launch
class AdminSettingsViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var settings by mutableStateOf<JsonObject?>(null)
private set
var saving by mutableStateOf(false)
private set
var actionError by mutableStateOf<String?>(null)
private set
var savedOk by mutableStateOf(false)
private set
init {
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
settings = container.repo.call { adminSettings() }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
fun save(edited: Map<String, Any>) {
saving = true; actionError = null; savedOk = false
viewModelScope.launch {
try {
val body = container.gson.toJsonTree(edited).asJsonObject
settings = container.repo.call { adminPatchSettings(body) }
savedOk = true
} catch (e: Exception) {
actionError = e.message
} finally {
saving = false
}
}
}
}
private fun humanize(key: String): String =
key.replace(Regex("([a-z])([A-Z])"), "$1 $2")
.replace('_', ' ')
.replaceFirstChar { it.uppercase() }
@Composable
fun AdminSettingsScreen() {
val container = LocalAppContainer.current
val vm: AdminSettingsViewModel = viewModel(factory = containerVMFactory(container) { AdminSettingsViewModel(it) })
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
val settings = vm.settings ?: return@ScreenState
SettingsForm(settings = settings, vm = vm)
}
}
@Composable
private fun SettingsForm(settings: JsonObject, vm: AdminSettingsViewModel) {
val p = BB.colors
// edited holds the current value for each editable flat key.
val edited = remember(settings) {
mutableStateMapOf<String, Any>().apply {
settings.entrySet().forEach { (k, v) ->
if (v.isJsonPrimitive) {
val prim = v.asJsonPrimitive
when {
prim.isBoolean -> put(k, prim.asBoolean)
prim.isNumber -> put(k, prim.asString)
else -> put(k, prim.asString)
}
}
}
}
}
ScreenColumn {
Text("System settings", style = MaterialTheme.typography.headlineMedium, color = p.text)
// Top-level primitives.
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val flat = settings.entrySet().filter { it.value.isJsonPrimitive }
if (flat.isEmpty()) {
Text("No editable top-level settings.", style = MaterialTheme.typography.bodyMedium, color = p.muted)
}
flat.forEachIndexed { i, (key, value) ->
if (i > 0) Hairline()
val prim = value.asJsonPrimitive
if (prim.isBoolean) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(humanize(key), style = MaterialTheme.typography.bodyLarge, color = p.text, modifier = Modifier.weight(1f))
Switch(
checked = edited[key] as? Boolean ?: false,
onCheckedChange = { edited[key] = it },
colors = SwitchDefaults.colors(
checkedThumbColor = p.onAccent,
checkedTrackColor = p.accent,
uncheckedTrackColor = p.surface2,
uncheckedBorderColor = p.border,
),
)
}
} else {
BBTextField(
value = edited[key] as? String ?: "",
onValueChange = { edited[key] = it },
label = humanize(key),
keyboardType = if (prim.isNumber) KeyboardType.Number else KeyboardType.Text,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
// Nested objects — show one level of primitive children (read-only display).
settings.entrySet().filter { it.value.isJsonObject }.forEach { (key, value) ->
val obj = value.asJsonObject
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionLabel(humanize(key))
obj.entrySet().filter { it.value.isJsonPrimitive }.forEach { (ck, cv) ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(humanize(ck), style = MaterialTheme.typography.bodyMedium, color = p.muted)
Text(cv.asString, style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
}
}
}
vm.actionError?.let { MessageBox(it, MessageKind.Error) }
if (vm.savedOk) MessageBox("Settings saved.", MessageKind.Ok)
BBButton(
"Save",
onClick = {
// Convert numeric strings back to numbers where the original was a number.
val out = edited.mapValues { (k, v) ->
val orig = settings.get(k)
if (v is String && orig != null && orig.isJsonPrimitive && orig.asJsonPrimitive.isNumber) {
v.toDoubleOrNull() ?: v
} else {
v
}
}
vm.save(out)
},
style = BBButtonStyle.Primary,
loading = vm.saving,
small = true,
)
}
}
@@ -0,0 +1,319 @@
package com.anypreta.bountyboard.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.Group
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
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.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.User
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBAvatar
import com.anypreta.bountyboard.ui.components.BBBadge
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.BBTextField
import com.anypreta.bountyboard.ui.components.ConfirmDialog
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.FilterPill
import com.anypreta.bountyboard.ui.components.Hairline
import com.anypreta.bountyboard.ui.components.MessageBox
import com.anypreta.bountyboard.ui.components.MessageKind
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.theme.BB
import kotlinx.coroutines.launch
class AdminUsersViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var users by mutableStateOf<List<User>>(emptyList())
private set
var actionError by mutableStateOf<String?>(null)
private set
var query by mutableStateOf("")
var role by mutableStateOf<String?>(null)
private set
init {
load()
}
fun applyRole(r: String?) {
role = r; load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
val r = container.repo.call { adminUsers(q = query.ifBlank { null }, role = role) }
users = r.users
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
fun save(
id: String,
name: String,
admin: Boolean,
consultant: Boolean,
developer: Boolean,
disabled: Boolean,
forcePasswordReset: Boolean,
onDone: () -> Unit,
) {
actionError = null
viewModelScope.launch {
try {
val body = container.gson.toJsonTree(
mapOf(
"name" to name,
"roles" to mapOf(
"admin" to admin,
"consultant" to consultant,
"developer" to developer,
),
"disabled" to disabled,
"forcePasswordReset" to forcePasswordReset,
),
).asJsonObject
container.repo.call { adminPatchUser(id, body) }
load()
onDone()
} catch (e: Exception) {
actionError = e.message
}
}
}
fun delete(id: String, onDone: () -> Unit) {
actionError = null
viewModelScope.launch {
try {
container.repo.action { adminDeleteUser(id) }
load()
onDone()
} catch (e: Exception) {
actionError = e.message
}
}
}
}
@Composable
fun AdminUsersScreen(onOpenProfile: (String) -> Unit) {
val container = LocalAppContainer.current
val vm: AdminUsersViewModel = viewModel(factory = containerVMFactory(container) { AdminUsersViewModel(it) })
val p = BB.colors
var roleMenu by remember { mutableStateOf(false) }
var editUser by remember { mutableStateOf<User?>(null) }
Column(Modifier.fillMaxWidth()) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
BBTextField(
value = vm.query,
onValueChange = { vm.query = it },
label = "Search users",
placeholder = "Name or email…",
leadingIcon = Icons.Outlined.Search,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box {
FilterPill(
text = vm.role?.replaceFirstChar { it.uppercase() } ?: "All roles",
icon = Icons.Outlined.FilterList,
onClick = { roleMenu = true },
)
DropdownMenu(expanded = roleMenu, onDismissRequest = { roleMenu = false }) {
DropdownMenuItem(text = { Text("All roles") }, onClick = { vm.applyRole(null); roleMenu = false })
listOf("admin", "consultant", "developer").forEach { r ->
DropdownMenuItem(
text = { Text(r.replaceFirstChar { it.uppercase() }) },
onClick = { vm.applyRole(r); roleMenu = false },
)
}
}
}
BBButton("Search", onClick = { vm.load() }, style = BBButtonStyle.Primary, small = true)
}
}
vm.actionError?.let {
Box(Modifier.padding(horizontal = 16.dp)) { MessageBox(it, MessageKind.Error) }
}
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
if (vm.users.isEmpty()) {
EmptyState(
title = "No users found",
subtitle = "Adjust your search or role filter.",
icon = Icons.Outlined.Group,
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(vm.users, key = { it.id }) { user ->
UserRow(user = user, onEdit = { editUser = user })
}
}
}
}
}
editUser?.let { user ->
EditUserDialog(
user = user,
onSave = { name, a, c, d, disabled, force ->
vm.save(user.id, name, a, c, d, disabled, force) { editUser = null }
},
onDelete = { vm.delete(user.id) { editUser = null } },
onViewProfile = { onOpenProfile(user.id); editUser = null },
onDismiss = { editUser = null },
)
}
}
@Composable
private fun UserRow(user: User, onEdit: () -> Unit) {
val p = BB.colors
BBCard(modifier = Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
BBAvatar(user.name.ifBlank { user.email }, user.avatarFileId, size = 40.dp)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(user.name.ifBlank { "(no name)" }, style = MaterialTheme.typography.titleMedium, color = p.text, maxLines = 1)
Text(user.email, style = MaterialTheme.typography.bodySmall, color = p.muted, maxLines = 1)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
if (user.roles.admin) BBBadge("Admin", color = p.accent)
if (user.roles.consultant) BBBadge("Consultant", color = p.muted)
if (user.roles.developer) BBBadge("Developer", color = p.muted)
if (user.disabled) BBBadge("Disabled", color = p.err)
}
}
BBButton("Edit", onClick = onEdit, style = BBButtonStyle.Default, small = true)
}
}
}
@Composable
private fun EditUserDialog(
user: User,
onSave: (String, Boolean, Boolean, Boolean, Boolean, Boolean) -> Unit,
onDelete: () -> Unit,
onViewProfile: () -> Unit,
onDismiss: () -> Unit,
) {
val p = BB.colors
var name by remember { mutableStateOf(user.name) }
var admin by remember { mutableStateOf(user.roles.admin) }
var consultant by remember { mutableStateOf(user.roles.consultant) }
var developer by remember { mutableStateOf(user.roles.developer) }
var disabled by remember { mutableStateOf(user.disabled) }
var force by remember { mutableStateOf(false) }
var confirmDelete by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = p.surface,
titleContentColor = p.text,
textContentColor = p.muted,
title = { Text("Edit user", style = MaterialTheme.typography.headlineSmall, color = p.text) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
BBTextField(value = name, onValueChange = { name = it }, label = "Name", modifier = Modifier.fillMaxWidth())
Hairline()
SwitchRow("Admin", admin) { admin = it }
SwitchRow("Consultant", consultant) { consultant = it }
SwitchRow("Developer", developer) { developer = it }
Hairline()
SwitchRow("Disabled", disabled) { disabled = it }
SwitchRow("Force password reset", force) { force = it }
Spacer(Modifier.height(2.dp))
BBButton("View profile", onClick = onViewProfile, style = BBButtonStyle.Ghost, small = true)
BBButton("Delete user", onClick = { confirmDelete = true }, style = BBButtonStyle.Danger, small = true)
}
},
confirmButton = {
BBButton(
"Save",
onClick = { onSave(name.trim(), admin, consultant, developer, disabled, force) },
style = BBButtonStyle.Primary,
small = true,
)
},
dismissButton = { BBButton("Cancel", onClick = onDismiss, style = BBButtonStyle.Ghost, small = true) },
)
if (confirmDelete) {
ConfirmDialog(
title = "Delete user",
message = "Permanently delete ${user.name.ifBlank { user.email }}? This cannot be undone.",
confirmText = "Delete",
danger = true,
onConfirm = { confirmDelete = false; onDelete() },
onDismiss = { confirmDelete = false },
)
}
}
@Composable
private fun SwitchRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
val p = BB.colors
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyLarge, color = p.text, modifier = Modifier.weight(1f))
Switch(
checked = checked,
onCheckedChange = onChange,
colors = SwitchDefaults.colors(
checkedThumbColor = p.onAccent,
checkedTrackColor = p.accent,
uncheckedTrackColor = p.surface2,
uncheckedBorderColor = p.border,
),
)
}
}
@@ -0,0 +1,323 @@
package com.anypreta.bountyboard.ui.admin
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.Customer
import com.anypreta.bountyboard.data.model.User
import com.anypreta.bountyboard.data.net.CustomerBody
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.BBTextField
import com.anypreta.bountyboard.ui.components.FilterPill
import com.anypreta.bountyboard.ui.components.Hairline
import com.anypreta.bountyboard.ui.components.MessageBox
import com.anypreta.bountyboard.ui.components.MessageKind
import com.anypreta.bountyboard.ui.components.ScreenColumn
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.SectionLabel
import com.anypreta.bountyboard.ui.theme.BB
import kotlinx.coroutines.launch
private val TICKETING_TYPES = listOf("demo", "jira", "azure_devops", "youtrack", "wekan")
/** Credential field keys per ticketing type. */
private fun credentialFields(type: String): List<Pair<String, Boolean>> = when (type) {
"jira" -> listOf("email" to false, "apiToken" to true)
"azure_devops" -> listOf("organization" to false, "project" to false, "pat" to true)
"youtrack" -> listOf("permanentToken" to true)
"wekan" -> listOf("username" to false, "password" to true)
else -> emptyList()
}
class CustomerEditViewModel(
private val container: AppContainer,
private val customerId: String,
) : ViewModel() {
val isNew = customerId == "new"
var loading by mutableStateOf(!isNew)
private set
var error by mutableStateOf<String?>(null)
private set
var consultants by mutableStateOf<List<User>>(emptyList())
private set
var existing by mutableStateOf<Customer?>(null)
private set
var saving by mutableStateOf(false)
private set
var actionError by mutableStateOf<String?>(null)
private set
var testing by mutableStateOf(false)
private set
var testMessage by mutableStateOf<String?>(null)
private set
var testOk by mutableStateOf(false)
private set
init {
load()
}
fun load() {
loading = !isNew; error = null
viewModelScope.launch {
try {
val cons = container.repo.call { adminUsers(role = "consultant") }
consultants = cons.users
if (!isNew) {
existing = container.repo.call { adminCustomer(customerId) }.customer
}
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
fun testConnection(body: CustomerBody) {
testing = true; testMessage = null
viewModelScope.launch {
try {
val r = if (isNew) {
container.repo.call { adminTestConnectionNew(body) }
} else {
container.repo.call { adminTestConnection(customerId) }
}
testOk = r.ok
testMessage = if (r.ok) "Connected in ${r.latencyMs}ms" else (r.error ?: "Connection failed")
} catch (e: Exception) {
testOk = false
testMessage = e.message
} finally {
testing = false
}
}
}
fun save(body: CustomerBody, onDone: () -> Unit) {
saving = true; actionError = null
viewModelScope.launch {
try {
if (isNew) {
container.repo.call { adminCreateCustomer(body) }
} else {
container.repo.call { adminPatchCustomer(customerId, body) }
}
onDone()
} catch (e: Exception) {
actionError = e.message
} finally {
saving = false
}
}
}
}
@Composable
fun CustomerEditScreen(customerId: String, onDone: () -> Unit) {
val container = LocalAppContainer.current
val vm: CustomerEditViewModel = viewModel(
key = "customer_$customerId",
factory = containerVMFactory(container) { CustomerEditViewModel(it, customerId) },
)
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
CustomerEditForm(vm = vm, onDone = onDone)
}
}
@Composable
private fun CustomerEditForm(vm: CustomerEditViewModel, onDone: () -> Unit) {
val p = BB.colors
val c = vm.existing
var name by remember { mutableStateOf(c?.name ?: "") }
var type by remember { mutableStateOf(c?.ticketing?.type ?: "demo") }
var baseUrl by remember { mutableStateOf(c?.ticketing?.baseUrl ?: "") }
var projectKey by remember { mutableStateOf(c?.ticketing?.projectKey ?: "") }
var pollInterval by remember { mutableStateOf((c?.ticketing?.pollIntervalSec ?: 60).toString()) }
var defaultBudget by remember {
mutableStateOf(c?.defaultBudget?.takeIf { it > 0.0 }?.let { it.toString() } ?: "")
}
val selectedConsultants = remember {
mutableStateMapOf<String, Boolean>().apply {
c?.consultantIds?.forEach { put(it, true) }
}
}
val credentials: SnapshotStateMap<String, String> = remember { mutableStateMapOf() }
var typeMenu by remember { mutableStateOf(false) }
fun buildBody(): CustomerBody {
val credMap = credentialFields(type)
.mapNotNull { (k, _) -> credentials[k]?.takeIf { it.isNotBlank() }?.let { k to it } }
.toMap()
return CustomerBody(
name = name.trim().ifBlank { null },
type = type,
baseUrl = baseUrl.trim().ifBlank { null },
projectKey = projectKey.trim().ifBlank { null },
pollIntervalSec = pollInterval.toIntOrNull(),
defaultBudget = defaultBudget.toDoubleOrNull(),
consultantIds = selectedConsultants.filterValues { it }.keys.toList(),
credentials = credMap.ifEmpty { null },
archived = c?.archived,
version = if (vm.isNew) null else c?.version,
)
}
ScreenColumn {
Text(
if (vm.isNew) "New project" else "Edit project",
style = MaterialTheme.typography.headlineMedium,
color = p.text,
)
BBTextField(value = name, onValueChange = { name = it }, label = "Name", modifier = Modifier.fillMaxWidth())
// Type selector
Column {
SectionLabel("Ticketing type", modifier = Modifier.padding(bottom = 6.dp))
Box {
FilterPill(
text = type,
icon = Icons.Outlined.ArrowDropDown,
showCaret = false,
onClick = { typeMenu = true },
)
DropdownMenu(expanded = typeMenu, onDismissRequest = { typeMenu = false }) {
TICKETING_TYPES.forEach { t ->
DropdownMenuItem(text = { Text(t) }, onClick = { type = t; typeMenu = false })
}
}
}
}
BBTextField(value = baseUrl, onValueChange = { baseUrl = it }, label = "Base URL", placeholder = "https://…", modifier = Modifier.fillMaxWidth())
BBTextField(value = projectKey, onValueChange = { projectKey = it }, label = "Project key", modifier = Modifier.fillMaxWidth())
BBTextField(
value = pollInterval,
onValueChange = { pollInterval = it.filter { ch -> ch.isDigit() } },
label = "Poll interval (sec)",
keyboardType = KeyboardType.Number,
modifier = Modifier.fillMaxWidth(),
)
BBTextField(
value = defaultBudget,
onValueChange = { defaultBudget = it.filter { ch -> ch.isDigit() || ch == '.' } },
label = "Default budget",
keyboardType = KeyboardType.Decimal,
modifier = Modifier.fillMaxWidth(),
)
// Consultants
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionLabel("Consultants")
if (vm.consultants.isEmpty()) {
Text("No consultants available.", style = MaterialTheme.typography.bodyMedium, color = p.muted)
} else {
vm.consultants.forEach { u ->
val checked = selectedConsultants[u.id] == true
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Checkbox(
checked = checked,
onCheckedChange = { selectedConsultants[u.id] = it },
colors = CheckboxDefaults.colors(
checkedColor = p.accent,
uncheckedColor = p.border,
checkmarkColor = p.onAccent,
),
)
Text(u.name.ifBlank { u.email }, style = MaterialTheme.typography.bodyLarge, color = p.text)
}
}
}
}
}
// Credentials
val credFields = credentialFields(type)
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionLabel("Credentials")
if (credFields.isEmpty()) {
Text("Demo needs no credentials.", style = MaterialTheme.typography.bodyMedium, color = p.muted)
} else {
if (!vm.isNew && vm.existing?.ticketing?.hasCredentials == true) {
Text("Credentials on file. Leave blank to keep unchanged.", style = MaterialTheme.typography.bodySmall, color = p.muted)
}
credFields.forEach { (key, secret) ->
BBTextField(
value = credentials[key] ?: "",
onValueChange = { credentials[key] = it },
label = key.replaceFirstChar { it.uppercase() },
isPassword = secret,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
vm.testMessage?.let {
MessageBox(it, if (vm.testOk) MessageKind.Ok else MessageKind.Error)
}
vm.actionError?.let { MessageBox(it, MessageKind.Error) }
Hairline()
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
BBButton(
"Test connection",
onClick = { vm.testConnection(buildBody()) },
style = BBButtonStyle.Default,
loading = vm.testing,
small = true,
)
BBButton(
"Save",
onClick = { vm.save(buildBody(), onDone) },
style = BBButtonStyle.Primary,
loading = vm.saving,
small = true,
)
}
}
}
@@ -0,0 +1,317 @@
package com.anypreta.bountyboard.ui.auth
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Dns
import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Mail
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.BBTextField
import com.anypreta.bountyboard.ui.components.Kicker
import com.anypreta.bountyboard.ui.components.MessageBox
import com.anypreta.bountyboard.ui.components.MessageKind
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.theme.Fraunces
import androidx.compose.ui.text.input.KeyboardType
@Composable
fun AuthFlow() {
val nav = rememberNavController()
NavHost(nav, startDestination = "login") {
composable("login") {
LoginScreen(
onRegister = { nav.navigate("register") },
onForgot = { nav.navigate("forgot") },
onOidc = { nav.navigate("oidc") },
)
}
composable("register") { RegisterScreen(onBack = { nav.popBackStack() }) }
composable("forgot") { ForgotScreen(onBack = { nav.popBackStack() }) }
composable("oidc") {
OidcWebViewScreen(onDone = { nav.popBackStack() })
}
}
}
@Composable
private fun AuthScaffold(content: @Composable () -> Unit) {
val p = BB.colors
Box(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 32.dp),
contentAlignment = Alignment.TopCenter,
) {
Column(
Modifier.widthIn(max = 460.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
content()
}
}
}
@Composable
private fun Masthead() {
val p = BB.colors
Spacer(Modifier.height(16.dp))
Kicker("Consulting Work Exchange")
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("", color = p.accent, fontFamily = Fraunces, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.displayMedium)
Text("Bounty Board", color = p.text, fontFamily = Fraunces, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.displayMedium)
}
Spacer(Modifier.height(6.dp))
Text("", color = p.accent, style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(24.dp))
}
@Composable
fun LoginScreen(onRegister: () -> Unit, onForgot: () -> Unit, onOidc: () -> Unit) {
val container = LocalAppContainer.current
val vm: AuthViewModel = viewModel(factory = containerVMFactory(container) { AuthViewModel(it) })
val p = BB.colors
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var serverExpanded by rememberSaveable { mutableStateOf(false) }
var baseUrl by rememberSaveable { mutableStateOf(vm.initialBaseUrl) }
AuthScaffold {
Masthead()
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("Sign in", style = MaterialTheme.typography.headlineMedium, color = p.text)
vm.error?.let { MessageBox(it, MessageKind.Error) }
vm.info?.let { MessageBox(it, MessageKind.Ok) }
BBTextField(
value = email, onValueChange = { email = it; vm.clearMessages() },
label = "Email", placeholder = "you@company.com",
keyboardType = KeyboardType.Email, leadingIcon = Icons.Outlined.Mail,
)
BBTextField(
value = password, onValueChange = { password = it; vm.clearMessages() },
label = "Password", isPassword = true, leadingIcon = Icons.Outlined.Lock,
)
BBButton(
"Sign in", onClick = { vm.applyBaseUrl(baseUrl); vm.login(email, password) },
style = BBButtonStyle.Primary, loading = vm.loading,
modifier = Modifier.fillMaxWidth(),
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f).height(1.dp).background(p.border.copy(alpha = 0.6f)))
Text("OR", style = MaterialTheme.typography.labelSmall, color = p.muted)
Box(Modifier.weight(1f).height(1.dp).background(p.border.copy(alpha = 0.6f)))
}
BBButton(
"Sign in with SSO",
onClick = { vm.applyBaseUrl(baseUrl); onOidc() },
style = BBButtonStyle.Default,
leadingIcon = Icons.Outlined.Key,
modifier = Modifier.fillMaxWidth(),
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextButton(onClick = onForgot) { Text("Forgot password?", color = p.accent, style = MaterialTheme.typography.bodyMedium) }
TextButton(onClick = onRegister) { Text("Create account", color = p.accent, style = MaterialTheme.typography.bodyMedium) }
}
}
}
Spacer(Modifier.height(16.dp))
// Server / base URL configuration
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Outlined.Dns, null, tint = p.muted, modifier = Modifier.height(18.dp))
Column {
Text("Server", style = MaterialTheme.typography.titleSmall, color = p.text)
Text(baseUrl, style = MaterialTheme.typography.labelSmall, color = p.muted, maxLines = 1)
}
}
TextButton(onClick = { serverExpanded = !serverExpanded }) {
Icon(
if (serverExpanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
"Toggle", tint = p.accent,
)
}
}
AnimatedVisibility(serverExpanded) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
BBTextField(
value = baseUrl, onValueChange = { baseUrl = it },
label = "Base URL", placeholder = "https://bountyboard.anypreta.com/",
keyboardType = KeyboardType.Uri,
supportingText = "The BountyBoard server this app connects to.",
)
BBButton(
"Save server", onClick = { vm.applyBaseUrl(baseUrl) },
style = BBButtonStyle.Default, small = true,
)
}
}
}
}
Spacer(Modifier.height(32.dp))
}
}
@Composable
fun RegisterScreen(onBack: () -> Unit) {
val container = LocalAppContainer.current
val vm: AuthViewModel = viewModel(factory = containerVMFactory(container) { AuthViewModel(it) })
val p = BB.colors
var name by rememberSaveable { mutableStateOf("") }
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
AuthScaffold {
Masthead()
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("Create developer account", style = MaterialTheme.typography.headlineMedium, color = p.text)
Text(
"Open registration creates a developer account in the global pool.",
style = MaterialTheme.typography.bodyMedium, color = p.muted,
)
vm.error?.let { MessageBox(it, MessageKind.Error) }
BBTextField(name, { name = it; vm.clearMessages() }, "Name", leadingIcon = Icons.Outlined.Person)
BBTextField(email, { email = it; vm.clearMessages() }, "Email", keyboardType = KeyboardType.Email, leadingIcon = Icons.Outlined.Mail)
BBTextField(password, { password = it; vm.clearMessages() }, "Password", isPassword = true, leadingIcon = Icons.Outlined.Lock, supportingText = "At least 8 characters.")
BBButton("Create account", onClick = { vm.register(email, name, password) }, style = BBButtonStyle.Primary, loading = vm.loading, modifier = Modifier.fillMaxWidth())
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
Text("Back to sign in", color = p.accent, textAlign = TextAlign.Center)
}
}
}
Spacer(Modifier.height(32.dp))
}
}
@Composable
fun ForgotScreen(onBack: () -> Unit) {
val container = LocalAppContainer.current
val vm: AuthViewModel = viewModel(factory = containerVMFactory(container) { AuthViewModel(it) })
val p = BB.colors
var email by rememberSaveable { mutableStateOf("") }
AuthScaffold {
Masthead()
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("Reset password", style = MaterialTheme.typography.headlineMedium, color = p.text)
Text(
"Enter your email and we'll send a reset link (if email is configured on the server).",
style = MaterialTheme.typography.bodyMedium, color = p.muted,
)
vm.error?.let { MessageBox(it, MessageKind.Error) }
vm.info?.let { MessageBox(it, MessageKind.Ok) }
BBTextField(email, { email = it; vm.clearMessages() }, "Email", keyboardType = KeyboardType.Email, leadingIcon = Icons.Outlined.Mail)
BBButton("Send reset link", onClick = { vm.forgot(email) {} }, style = BBButtonStyle.Primary, loading = vm.loading, modifier = Modifier.fillMaxWidth())
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
Text("Back to sign in", color = p.accent, textAlign = TextAlign.Center)
}
}
}
Spacer(Modifier.height(32.dp))
}
}
@Composable
fun ForcedChangePasswordScreen() {
val container = LocalAppContainer.current
val vm: AuthViewModel = viewModel(factory = containerVMFactory(container) { AuthViewModel(it) })
val p = BB.colors
var current by rememberSaveable { mutableStateOf("") }
var next by rememberSaveable { mutableStateOf("") }
var confirm by rememberSaveable { mutableStateOf("") }
AuthScaffold {
Masthead()
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("Change your password", style = MaterialTheme.typography.headlineMedium, color = p.text)
Text(
"Your account requires a new password before continuing.",
style = MaterialTheme.typography.bodyMedium, color = p.muted,
)
vm.error?.let { MessageBox(it, MessageKind.Error) }
BBTextField(current, { current = it; vm.clearMessages() }, "Current password", isPassword = true, leadingIcon = Icons.Outlined.Lock)
BBTextField(next, { next = it; vm.clearMessages() }, "New password", isPassword = true, leadingIcon = Icons.Outlined.Lock, supportingText = "At least 8 characters.")
BBTextField(
confirm, { confirm = it; vm.clearMessages() }, "Confirm new password", isPassword = true,
leadingIcon = Icons.Outlined.Lock,
isError = confirm.isNotEmpty() && confirm != next,
)
BBButton(
"Update password",
onClick = { vm.changePassword(current, next) {} },
enabled = next.isNotEmpty() && next == confirm,
style = BBButtonStyle.Primary, loading = vm.loading, modifier = Modifier.fillMaxWidth(),
)
TextButton(
onClick = { container.session.clear(); container.cookieJar.clear() },
modifier = Modifier.fillMaxWidth(),
) {
Text("Sign out", color = p.muted, textAlign = TextAlign.Center)
}
}
}
Spacer(Modifier.height(32.dp))
}
}
@@ -0,0 +1,131 @@
package com.anypreta.bountyboard.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.net.ChangePasswordRequest
import com.anypreta.bountyboard.data.net.ForgotRequest
import com.anypreta.bountyboard.data.net.LoginRequest
import com.anypreta.bountyboard.data.net.RegisterRequest
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.launch
class AuthViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
private set
var info by mutableStateOf<String?>(null)
private set
val initialBaseUrl: String get() = container.baseUrl.value
fun applyBaseUrl(url: String, onApplied: () -> Unit = {}) {
viewModelScope.launch {
val normalized = com.anypreta.bountyboard.data.net.HostSelectionInterceptor.normalize(url)
container.setBaseUrl(normalized)
container.settings.setBaseUrl(normalized)
info = "Server set to $normalized"
error = null
onApplied()
}
}
fun login(email: String, password: String) {
if (email.isBlank() || password.isBlank()) {
error = "Enter your email and password."
return
}
loading = true; error = null; info = null
viewModelScope.launch {
try {
val r = container.repo.call { login(LoginRequest(email.trim(), password)) }
container.session.setSession(r.user, r.mustChangePassword, r.oidcEnabled)
} catch (e: Exception) {
error = e.message ?: "Login failed."
} finally {
loading = false
}
}
}
fun register(email: String, name: String, password: String) {
when {
name.isBlank() -> { error = "Enter your name."; return }
email.isBlank() -> { error = "Enter your email."; return }
password.length < 8 -> { error = "Password must be at least 8 characters."; return }
}
loading = true; error = null; info = null
viewModelScope.launch {
try {
val r = container.repo.call { register(RegisterRequest(email.trim(), name.trim(), password)) }
container.session.setSession(r.user, r.mustChangePassword, r.oidcEnabled)
} catch (e: Exception) {
error = e.message ?: "Registration failed."
} finally {
loading = false
}
}
}
fun forgot(email: String, onDone: () -> Unit) {
if (email.isBlank()) { error = "Enter your email."; return }
loading = true; error = null; info = null
viewModelScope.launch {
try {
container.repo.call { forgot(ForgotRequest(email.trim())) }
info = "If an account exists, a reset link has been sent."
onDone()
} catch (e: Exception) {
error = e.message ?: "Could not send reset email."
} finally {
loading = false
}
}
}
fun changePassword(current: String, new: String, onDone: () -> Unit) {
when {
current.isBlank() -> { error = "Enter your current password."; return }
new.length < 8 -> { error = "New password must be at least 8 characters."; return }
}
loading = true; error = null; info = null
viewModelScope.launch {
try {
container.repo.action { changePassword(ChangePasswordRequest(current, new)) }
// Refresh session flags.
val me = container.repo.call { me() }
container.session.setSession(me.user, me.mustChangePassword, me.oidcEnabled)
onDone()
} catch (e: Exception) {
error = e.message ?: "Could not change password."
} finally {
loading = false
}
}
}
/** After the OIDC WebView flow lands the session cookie, establish the session. */
fun completeOidc(onResult: (Boolean) -> Unit) {
loading = true; error = null
viewModelScope.launch {
try {
val me = container.repo.call { me() }
container.session.setSession(me.user, me.mustChangePassword, me.oidcEnabled)
onResult(true)
} catch (e: Exception) {
error = "SSO sign-in failed: ${e.message}"
onResult(false)
} finally {
loading = false
}
}
}
fun clearMessages() {
error = null; info = null
}
}
@@ -0,0 +1,137 @@
package com.anypreta.bountyboard.ui.auth
import android.annotation.SuppressLint
import android.net.Uri
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
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.outlined.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.DiamondBrand
import com.anypreta.bountyboard.ui.components.Hairline
import com.anypreta.bountyboard.ui.components.MessageBox
import com.anypreta.bountyboard.ui.theme.BB
/**
* OIDC SSO via the server's browser redirect flow (GET /api/v1/auth/oidc/login →
* IdP → /api/v1/auth/oidc/callback sets the session cookies → 302 home). We host
* it in a WebView, detect the final redirect back to our own host, then import the
* session cookies from the WebView's CookieManager into the app's OkHttp cookie jar
* and establish the session via /auth/me.
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun OidcWebViewScreen(onDone: (Boolean) -> Unit) {
val container = LocalAppContainer.current
val vm: AuthViewModel = viewModel(factory = containerVMFactory(container) { AuthViewModel(it) })
val p = BB.colors
val baseUrl = container.baseUrl.value
val host = remember(baseUrl) { Uri.parse(baseUrl).host ?: "" }
val startUrl = baseUrl.trimEnd('/') + "/api/v1/auth/oidc/login"
var progress by remember { mutableStateOf(true) }
var handled by remember { mutableStateOf(false) }
var failure by remember { mutableStateOf<String?>(null) }
fun complete(success: Boolean, reason: String? = null) {
if (handled) return
handled = true
if (success) {
// Pull cookies the server set in the WebView into the OkHttp jar.
CookieManager.getInstance().flush()
val header = CookieManager.getInstance().getCookie(baseUrl)
container.cookieJar.importCookieHeader(host, header)
if (container.cookieJar.cookieValue(host, "bb_session") != null) {
vm.completeOidc { ok ->
if (ok) onDone(true) else { handled = false; failure = "SSO sign-in could not be completed." }
}
} else {
handled = false
failure = "Single sign-on isn't configured on this server."
}
} else {
failure = reason ?: "Single sign-on was cancelled or failed."
}
}
Column(Modifier.fillMaxSize()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
DiamondBrand("Single sign-on")
IconButton(onClick = { onDone(false) }) {
Icon(Icons.Outlined.Close, "Cancel", tint = p.text)
}
}
Hairline()
if (progress) {
LinearProgressIndicator(Modifier.fillMaxWidth(), color = p.accent, trackColor = p.surface2)
}
failure?.let {
Box(Modifier.padding(16.dp)) { MessageBox(it) }
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
CookieManager.getInstance().setAcceptCookie(true)
WebView(ctx).apply {
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url
// The final redirect lands back on our host, OUTSIDE the oidc endpoints.
if (url.host == host && !(url.path ?: "").startsWith("/api/v1/auth/oidc")) {
val full = url.toString()
val isError = full.contains("error", ignoreCase = true) ||
(url.path ?: "").startsWith("/login")
complete(!isError, if (isError) "Single sign-on was rejected." else null)
return true
}
return false
}
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
progress = true
}
override fun onPageFinished(view: WebView?, url: String?) {
progress = false
}
}
loadUrl(startUrl)
}
},
)
}
}
@@ -0,0 +1,220 @@
package com.anypreta.bountyboard.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Inbox
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.theme.Fraunces
import com.anypreta.bountyboard.ui.theme.KickerStyle
@Composable
fun BBAvatar(
name: String,
fileId: String?,
modifier: Modifier = Modifier,
size: Dp = 32.dp,
) {
val p = BB.colors
val container = LocalAppContainer.current
val context = LocalContext.current
val shape = RoundedCornerShape((p.radius + 1).dp)
val initials = remember(name) { computeInitials(name) }
Box(
modifier = modifier
.size(size)
.clip(shape)
.background(p.accent)
.border(1.dp, p.border, shape),
contentAlignment = Alignment.Center,
) {
if (!fileId.isNullOrEmpty()) {
SubcomposeAsyncImage(
model = ImageRequest.Builder(context).data(container.fileUrl(fileId)).build(),
imageLoader = container.imageLoader,
contentDescription = name,
modifier = Modifier.fillMaxSize(),
loading = { InitialsLabel(initials, size, p.onAccent) },
error = { InitialsLabel(initials, size, p.onAccent) },
)
} else {
InitialsLabel(initials, size, p.onAccent)
}
}
}
@Composable
private fun InitialsLabel(initials: String, size: Dp, color: Color) {
Text(
initials,
fontFamily = Fraunces,
fontWeight = FontWeight.Bold,
color = color,
fontSize = (size.value * 0.4f).sp,
)
}
private fun computeInitials(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
return when {
parts.isEmpty() -> "?"
parts.size == 1 -> parts[0].take(2).uppercase()
else -> (parts[0].take(1) + parts.last().take(1)).uppercase()
}
}
@Composable
fun Kicker(text: String, modifier: Modifier = Modifier) {
Text(text.uppercase(), style = KickerStyle, color = BB.colors.muted, modifier = modifier)
}
@Composable
fun DiamondBrand(text: String, modifier: Modifier = Modifier) {
val p = BB.colors
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text("", color = p.accent, fontFamily = Fraunces, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge)
Text(text, color = p.text, fontFamily = Fraunces, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge)
}
}
@Composable
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(text.uppercase(), style = MaterialTheme.typography.labelSmall, color = BB.colors.muted, modifier = modifier)
}
@Composable
fun Hairline(modifier: Modifier = Modifier) {
Box(
modifier
.fillMaxWidth()
.height(1.dp)
.background(BB.colors.border.copy(alpha = 0.6f)),
)
}
@Composable
fun LoadingBox(modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize().padding(48.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = BB.colors.accent, strokeWidth = 2.dp)
}
}
@Composable
fun EmptyState(
title: String,
subtitle: String? = null,
icon: ImageVector = Icons.Outlined.Inbox,
modifier: Modifier = Modifier,
action: (@Composable () -> Unit)? = null,
) {
val p = BB.colors
Column(
modifier.fillMaxWidth().padding(40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(icon, null, tint = p.muted.copy(alpha = 0.6f), modifier = Modifier.size(48.dp))
Text(title, style = MaterialTheme.typography.headlineSmall, color = p.text, textAlign = TextAlign.Center)
if (subtitle != null) {
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = p.muted, textAlign = TextAlign.Center)
}
if (action != null) {
Spacer(Modifier.height(4.dp))
action()
}
}
}
enum class MessageKind { Error, Ok, Info }
@Composable
fun MessageBox(text: String, kind: MessageKind = MessageKind.Error, modifier: Modifier = Modifier) {
val p = BB.colors
val color = when (kind) {
MessageKind.Error -> p.err
MessageKind.Ok -> p.ok
MessageKind.Info -> p.accent
}
Row(
modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.clip(RoundedCornerShape(p.radius.dp))
.background(color.copy(alpha = 0.08f))
.border(1.dp, color.copy(alpha = 0.5f), RoundedCornerShape(p.radius.dp)),
) {
Box(Modifier.width(4.dp).fillMaxHeight().background(color))
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = color,
modifier = Modifier.padding(12.dp),
)
}
}
@Composable
fun ConfirmDialog(
title: String,
message: String,
confirmText: String = "Confirm",
danger: Boolean = false,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
val p = BB.colors
AlertDialog(
onDismissRequest = onDismiss,
containerColor = p.surface,
titleContentColor = p.text,
textContentColor = p.muted,
title = { Text(title, style = MaterialTheme.typography.headlineSmall, color = p.text) },
text = { Text(message, style = MaterialTheme.typography.bodyMedium, color = p.muted) },
confirmButton = {
BBButton(
confirmText,
onClick = onConfirm,
style = if (danger) BBButtonStyle.Danger else BBButtonStyle.Primary,
small = true,
)
},
dismissButton = {
BBButton("Cancel", onClick = onDismiss, style = BBButtonStyle.Ghost, small = true)
},
)
}
@@ -0,0 +1,303 @@
package com.anypreta.bountyboard.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
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.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.theme.BBPalette
import com.anypreta.bountyboard.ui.theme.SplineMono
import com.anypreta.bountyboard.ui.theme.hardShadow
import com.anypreta.bountyboard.ui.util.formatBounty
import com.anypreta.bountyboard.ui.util.statusLabel
// ---------------------------------------------------------------------------
// Buttons
// ---------------------------------------------------------------------------
enum class BBButtonStyle { Primary, Default, Danger, Ghost }
@Composable
fun BBButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
style: BBButtonStyle = BBButtonStyle.Default,
enabled: Boolean = true,
loading: Boolean = false,
leadingIcon: ImageVector? = null,
small: Boolean = false,
) {
val p = BB.colors
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val shape = RoundedCornerShape(p.radius.dp)
val hasShadow = style != BBButtonStyle.Ghost
val shadowOffset = if (small) 1.dp else 2.dp
val bg: Color
val content: Color
val borderColor: Color
when (style) {
BBButtonStyle.Primary -> {
bg = p.accent; content = p.onAccent; borderColor = blend(p.accent, Color.Black, 0.35f)
}
BBButtonStyle.Danger -> {
bg = p.err; content = Color.White; borderColor = blend(p.err, Color.Black, 0.3f)
}
BBButtonStyle.Ghost -> {
bg = Color.Transparent; content = p.text; borderColor = p.border
}
BBButtonStyle.Default -> {
bg = p.surface2; content = p.text; borderColor = p.border
}
}
val alpha = if (enabled && !loading) 1f else 0.45f
val pressShift = if (pressed && hasShadow) shadowOffset else 0.dp
Box(
modifier = modifier
.then(if (hasShadow && !pressed) Modifier.hardShadow(p, shadowOffset, p.radius.dp) else Modifier)
.offset(pressShift, pressShift)
.clip(shape)
.background(bg.copy(alpha = if (style == BBButtonStyle.Ghost) 1f else alpha))
.border(1.dp, borderColor.copy(alpha = alpha), shape)
.clickableNoRipple(enabled = enabled && !loading, interaction = interaction, onClick = onClick)
.padding(
horizontal = if (small) 12.dp else 18.dp,
vertical = if (small) 6.dp else 10.dp,
),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = content)
} else {
if (leadingIcon != null) {
Icon(leadingIcon, contentDescription = null, tint = content.copy(alpha = alpha), modifier = Modifier.size(16.dp))
}
Text(
text.uppercase(),
style = if (small) MaterialTheme.typography.labelMedium else MaterialTheme.typography.labelLarge,
color = content.copy(alpha = alpha),
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
}
}
// ---------------------------------------------------------------------------
// Cards
// ---------------------------------------------------------------------------
@Composable
fun BBCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
padding: PaddingValues = PaddingValues(16.dp),
topStrip: Boolean = false,
content: @Composable () -> Unit,
) {
val p = BB.colors
val shape = RoundedCornerShape(p.radius.dp)
val interaction = remember { MutableInteractionSource() }
Box(
modifier = modifier
.hardShadow(p, 3.dp, p.radius.dp)
.clip(shape)
.background(p.surface)
.border(1.dp, p.border, shape)
.then(if (onClick != null) Modifier.clickableNoRipple(true, interaction, onClick) else Modifier),
) {
Column {
if (topStrip) {
Box(
Modifier
.height(6.dp)
.background(p.accent)
.border(0.dp, Color.Transparent),
)
}
Box(Modifier.padding(padding)) { content() }
}
}
}
// ---------------------------------------------------------------------------
// Badges & chips
// ---------------------------------------------------------------------------
@Composable
fun BBBadge(
text: String,
modifier: Modifier = Modifier,
color: Color = BB.colors.muted,
filled: Boolean = false,
) {
val p = BB.colors
val shape = RoundedCornerShape(p.radius.dp)
Box(
modifier = modifier
.clip(shape)
.background(if (filled) color else Color.Transparent)
.border(1.dp, color.copy(alpha = if (filled) 1f else 0.7f), shape)
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
text.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = if (filled) p.surface else color,
)
}
}
@Composable
fun StatusBadge(status: String, modifier: Modifier = Modifier) {
BBBadge(statusLabel(status), modifier = modifier, color = statusColor(status, BB.colors))
}
@Composable
fun BountyChip(value: Double, modifier: Modifier = Modifier, large: Boolean = false) {
val p = BB.colors
val shape = RoundedCornerShape(p.radius.dp)
Box(
modifier = modifier
.clip(shape)
.background(p.surface)
.border(1.5.dp, p.accent, shape)
.padding(horizontal = if (large) 12.dp else 9.dp, vertical = if (large) 5.dp else 3.dp),
) {
Text(
"${formatBounty(value)}",
fontFamily = SplineMono,
fontWeight = FontWeight.Bold,
color = p.accent,
style = if (large) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
)
}
}
// ---------------------------------------------------------------------------
// Text fields
// ---------------------------------------------------------------------------
@Composable
fun BBTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
placeholder: String? = null,
singleLine: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text,
isPassword: Boolean = false,
enabled: Boolean = true,
leadingIcon: ImageVector? = null,
minLines: Int = 1,
supportingText: String? = null,
isError: Boolean = false,
) {
val p = BB.colors
Column(modifier) {
Text(
label,
style = MaterialTheme.typography.titleSmall,
color = p.text,
modifier = Modifier.padding(bottom = 6.dp),
)
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
placeholder = placeholder?.let { { Text(it, color = p.muted, style = MaterialTheme.typography.bodyMedium) } },
singleLine = singleLine,
minLines = minLines,
enabled = enabled,
isError = isError,
shape = RoundedCornerShape(p.radius.dp),
leadingIcon = leadingIcon?.let { { Icon(it, null, tint = p.muted, modifier = Modifier.size(18.dp)) } },
visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = if (isPassword) KeyboardType.Password else keyboardType,
),
supportingText = supportingText?.let { { Text(it, style = MaterialTheme.typography.bodySmall, color = if (isError) p.err else p.muted) } },
textStyle = MaterialTheme.typography.bodyLarge,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = p.accent,
unfocusedBorderColor = p.border,
errorBorderColor = p.err,
focusedContainerColor = p.bg,
unfocusedContainerColor = p.bg,
disabledContainerColor = p.surface2,
cursorColor = p.accent,
focusedTextColor = p.text,
unfocusedTextColor = p.text,
),
)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fun statusColor(status: String, p: BBPalette): Color = when (status) {
"approved" -> p.ok
"changes_requested" -> p.warn
"in_review" -> p.accent
"archived" -> p.err
else -> p.muted
}
fun blend(a: Color, b: Color, t: Float): Color = Color(
red = a.red * (1 - t) + b.red * t,
green = a.green * (1 - t) + b.green * t,
blue = a.blue * (1 - t) + b.blue * t,
alpha = a.alpha,
)
private fun Modifier.clickableNoRipple(
enabled: Boolean,
interaction: MutableInteractionSource,
onClick: () -> Unit,
): Modifier = this.clickable(
interactionSource = interaction,
indication = null,
enabled = enabled,
onClick = onClick,
)
@@ -0,0 +1,120 @@
package com.anypreta.bountyboard.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.anypreta.bountyboard.ui.theme.BB
@Composable
fun FilterPill(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
showCaret: Boolean = true,
) {
val p = BB.colors
val shape = RoundedCornerShape(p.radius.dp)
Row(
modifier
.clip(shape)
.background(p.surface2)
.border(1.dp, p.border, shape)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (icon != null) Icon(icon, null, tint = p.muted, modifier = Modifier.size(16.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, color = p.text, maxLines = 1)
if (showCaret) Icon(Icons.Outlined.ArrowDropDown, null, tint = p.muted, modifier = Modifier.size(18.dp))
}
}
/**
* Generic single-field dialog: optional or required note/text, with confirm/cancel.
*/
@Composable
fun NoteDialog(
title: String,
label: String,
confirmText: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
message: String? = null,
required: Boolean = false,
placeholder: String? = null,
initial: String = "",
danger: Boolean = false,
loading: Boolean = false,
) {
val p = BB.colors
var text by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = p.surface,
title = { Text(title, style = MaterialTheme.typography.headlineSmall, color = p.text) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (message != null) Text(message, style = MaterialTheme.typography.bodyMedium, color = p.muted)
BBTextField(
value = text,
onValueChange = { text = it },
label = label,
placeholder = placeholder,
singleLine = false,
minLines = 3,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
BBButton(
confirmText,
onClick = { onConfirm(text.trim()) },
style = if (danger) BBButtonStyle.Danger else BBButtonStyle.Primary,
enabled = !required || text.isNotBlank(),
loading = loading,
small = true,
)
},
dismissButton = { BBButton("Cancel", onClick = onDismiss, style = BBButtonStyle.Ghost, small = true) },
)
}
@Composable
fun ClaimDialog(onConfirm: (String?) -> Unit, onDismiss: () -> Unit) {
NoteDialog(
title = "Request assignment",
message = "Optionally add a pitch — why you're a good fit for this task.",
label = "Pitch note (optional)",
placeholder = "I've done similar work…",
confirmText = "Request",
required = false,
onConfirm = { onConfirm(it.ifBlank { null }) },
onDismiss = onDismiss,
)
}
@@ -0,0 +1,69 @@
package com.anypreta.bountyboard.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.anypreta.bountyboard.ui.theme.BB
/**
* Standard state wrapper: shows a centered loader, an error with retry, or content.
* Pass [empty] = true to show an empty placeholder via [emptyContent].
*/
@Composable
fun ScreenState(
loading: Boolean,
error: String?,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
Box(modifier.fillMaxSize()) {
when {
loading -> LoadingBox()
error != null -> Column(
Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
EmptyState(
title = "Something went wrong",
subtitle = error,
icon = Icons.Outlined.ErrorOutline,
action = if (onRetry != null) {
{ BBButton("Retry", onClick = onRetry, style = BBButtonStyle.Primary, small = true) }
} else null,
)
}
else -> content()
}
}
}
/** Scrollable padded column used by most form/detail screens. */
@Composable
fun ScreenColumn(
modifier: Modifier = Modifier,
padding: PaddingValues = PaddingValues(16.dp),
spacing: Dp = 14.dp,
scroll: Boolean = true,
content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit,
) {
val base = modifier.fillMaxSize()
Column(
modifier = if (scroll) base.verticalScroll(rememberScrollState()).padding(padding) else base.padding(padding),
verticalArrangement = Arrangement.spacedBy(spacing),
content = content,
)
}
@@ -0,0 +1,109 @@
package com.anypreta.bountyboard.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ChecklistRtl
import androidx.compose.material.icons.outlined.Link
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.anypreta.bountyboard.data.model.Task
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.util.htmlToPlain
import com.anypreta.bountyboard.ui.util.parseInstant
/**
* The canonical task card used across boards. Bounty chip top-left, status/age
* badge top-right, Fraunces title, description preview, AC count + external key,
* optional footer (actions).
*/
@Composable
fun TaskCard(
task: Task,
onClick: () -> Unit,
modifier: Modifier = Modifier,
customerName: String? = null,
showStatus: Boolean = false,
showAge: Boolean = false,
footer: (@Composable () -> Unit)? = null,
) {
val p = BB.colors
BBCard(modifier = modifier.fillMaxWidth(), onClick = onClick, topStrip = true, padding = androidx.compose.foundation.layout.PaddingValues(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
BountyChip(task.bounty)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (showAge) AgeBadge(task)
if (task.external?.orphaned == true) BBBadge("Orphaned", color = p.err)
if (showStatus) StatusBadge(task.status)
}
}
Text(
task.title,
style = MaterialTheme.typography.titleLarge,
color = p.text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (task.description.isNotBlank()) {
Text(
htmlToPlain(task.description),
style = MaterialTheme.typography.bodyMedium,
color = p.muted,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
customerName?.let {
Text(it, style = MaterialTheme.typography.labelSmall, color = p.muted, maxLines = 1)
}
task.external?.key?.let {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
Icon(Icons.Outlined.Link, null, tint = p.muted, modifier = Modifier.height(13.dp).width(13.dp))
Text(it, style = MaterialTheme.typography.labelSmall, color = p.muted)
}
}
val acCount = task.acceptanceCriteria?.size ?: 0
if (acCount > 0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
Icon(Icons.Outlined.ChecklistRtl, null, tint = p.muted, modifier = Modifier.height(13.dp).width(13.dp))
Text("$acCount AC", style = MaterialTheme.typography.labelSmall, color = p.muted)
}
}
}
if (footer != null) {
Spacer(Modifier.height(2.dp))
footer()
}
}
}
}
@Composable
private fun AgeBadge(task: Task) {
val p = BB.colors
val published = parseInstant(task.publishedAt) ?: return
val days = ((System.currentTimeMillis() - published.time) / 86_400_000L).toInt()
when {
days >= 14 -> BBBadge("14d+", color = p.err)
days >= 7 -> BBBadge("7d+", color = p.warn)
else -> {}
}
}
@@ -0,0 +1,231 @@
package com.anypreta.bountyboard.ui.consultant
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.Customer
import com.anypreta.bountyboard.data.model.Task
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.FilterPill
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.SectionLabel
import com.anypreta.bountyboard.ui.components.TaskCard
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.util.statusLabel
import kotlinx.coroutines.launch
private val STATUS_FILTERS =
listOf("imported", "atomizing", "atomized", "published", "claim_requested")
@Composable
fun ConsultantBoardScreen(onOpenTask: (String) -> Unit) {
val container = LocalAppContainer.current
val vm: ConsultantBoardViewModel =
viewModel(factory = containerVMFactory(container) { ConsultantBoardViewModel(it) })
val p = BB.colors
var customerMenu by remember { mutableStateOf(false) }
var statusMenu by remember { mutableStateOf(false) }
Column(Modifier.fillMaxWidth()) {
Column(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box {
FilterPill(
text = vm.customerId?.let { vm.customerName(it) } ?: "All projects",
icon = Icons.Outlined.FilterList,
onClick = { customerMenu = true },
)
DropdownMenu(expanded = customerMenu, onDismissRequest = { customerMenu = false }) {
DropdownMenuItem(
text = { Text("All projects") },
onClick = { vm.setCustomer(null); customerMenu = false },
)
vm.customers.forEach { c ->
DropdownMenuItem(
text = { Text(c.name) },
onClick = { vm.setCustomer(c.id); customerMenu = false },
)
}
}
}
Box {
FilterPill(
text = vm.status?.let { statusLabel(it) } ?: "All statuses",
onClick = { statusMenu = true },
)
DropdownMenu(expanded = statusMenu, onDismissRequest = { statusMenu = false }) {
DropdownMenuItem(
text = { Text("All statuses") },
onClick = { vm.applyStatus(null); statusMenu = false },
)
STATUS_FILTERS.forEach { s ->
DropdownMenuItem(
text = { Text(statusLabel(s)) },
onClick = { vm.applyStatus(s); statusMenu = false },
)
}
}
}
}
Text(
"Tap a task to subdivide, extend, edit or publish.",
style = MaterialTheme.typography.bodySmall,
color = p.muted,
)
}
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
if (vm.tasks.isEmpty()) {
EmptyState(
title = "No tasks",
subtitle = "Imported tickets show up here after a project sync — then you can atomize and publish them.",
icon = Icons.Outlined.Storefront,
)
} else {
val byCustomer = vm.customers.filter { c -> vm.tasks.any { it.customerId == c.id } }
val unknown = vm.tasks.filter { t -> vm.customers.none { it.id == t.customerId } }
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
byCustomer.forEach { customer ->
val tasks = vm.tasks.filter { it.customerId == customer.id }
item(key = "header_${customer.id}") {
SectionLabel(customer.name, modifier = Modifier.padding(top = 4.dp))
}
items(tasks, key = { it.id }) { task ->
ConsultantTaskRow(task, onOpenTask, vm)
}
}
if (unknown.isNotEmpty()) {
item(key = "header_unknown") {
SectionLabel("Other", modifier = Modifier.padding(top = 4.dp))
}
items(unknown, key = { it.id }) { task ->
ConsultantTaskRow(task, onOpenTask, vm)
}
}
}
}
}
}
}
@Composable
private fun ConsultantTaskRow(
task: Task,
onOpenTask: (String) -> Unit,
vm: ConsultantBoardViewModel,
) {
TaskCard(
task = task,
onClick = { onOpenTask(task.id) },
showStatus = true,
footer = if (task.status == "atomized") {
{
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
BBButton(
"Publish",
onClick = { vm.publish(task.id) },
style = BBButtonStyle.Primary,
small = true,
)
}
}
} else null,
)
}
class ConsultantBoardViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var customers by mutableStateOf<List<Customer>>(emptyList())
private set
var tasks by mutableStateOf<List<Task>>(emptyList())
private set
var customerId by mutableStateOf<String?>(null)
private set
var status by mutableStateOf<String?>(null)
private set
init {
load()
}
fun setCustomer(id: String?) {
customerId = id; load()
}
fun applyStatus(s: String?) {
status = s; load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
val r = container.repo.call { consultantBoard(customerId = customerId, status = status) }
customers = r.customers
tasks = r.tasks
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
fun publish(id: String) {
viewModelScope.launch {
try {
container.repo.call { publish(id) }
load()
} catch (e: Exception) {
error = e.message
}
}
}
fun customerName(id: String): String? = customers.firstOrNull { it.id == id }?.name
}
@@ -0,0 +1,374 @@
package com.anypreta.bountyboard.ui.consultant
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.QueryStats
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.Hairline
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.SectionLabel
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.theme.Fraunces
import com.anypreta.bountyboard.ui.theme.SplineMono
import com.anypreta.bountyboard.ui.util.formatBounty
import com.anypreta.bountyboard.ui.util.formatMinutes
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
// ---------------------------------------------------------------------------
// Defensive JsonObject readers
// ---------------------------------------------------------------------------
private fun JsonObject.num(key: String): Double? {
val el = get(key) ?: return null
if (el.isJsonNull) return null
return try {
if (el.isJsonPrimitive) el.asDouble else null
} catch (_: Exception) {
null
}
}
private fun JsonObject.str(key: String): String? {
val el = get(key) ?: return null
if (el.isJsonNull) return null
return try {
if (el.isJsonPrimitive) el.asString else null
} catch (_: Exception) {
null
}
}
private fun JsonObject.arr(key: String): JsonArray? {
val el = get(key) ?: return null
return if (el.isJsonArray) el.asJsonArray else null
}
/** Known scalar metric keys we render with bespoke labels/formatting, in priority order. */
private val KNOWN_KEYS = listOf(
"totalBounty", "tasksCompleted", "approvalRate", "avgLeadTimeHours",
"boardDepth", "timeLoggedMinutes",
)
private fun humanize(key: String): String =
key.replace(Regex("([a-z])([A-Z])"), "$1 $2")
.replace('_', ' ')
.replaceFirstChar { it.uppercase() }
private data class Stat(val label: String, val value: String)
private fun statFor(key: String, v: Double): Stat = when (key) {
"totalBounty" -> Stat("Total bounty", "${formatBounty(v)}")
"tasksCompleted" -> Stat("Tasks completed", v.roundToInt().toString())
"approvalRate" -> {
val pct = if (v <= 1.0) v * 100 else v
Stat("Approval rate", "${formatBounty(pct)}%")
}
"avgLeadTimeHours" -> Stat("Avg lead time", "${formatBounty(v)}h")
"boardDepth" -> Stat("Board depth", v.roundToInt().toString())
"timeLoggedMinutes" -> Stat("Time logged", formatMinutes(v.roundToInt()))
else -> Stat(humanize(key), if (v == v.toLong().toDouble()) v.toLong().toString() else formatBounty(v))
}
private data class Breakdown(val label: String, val totalBounty: Double, val tasksCompleted: Int)
private fun parseBreakdowns(arr: JsonArray?): List<Breakdown> {
if (arr == null) return emptyList()
return arr.mapNotNull { el ->
if (!el.isJsonObject) return@mapNotNull null
val o = el.asJsonObject
val label = o.str("label") ?: o.str("key") ?: o.str("name") ?: return@mapNotNull null
Breakdown(
label = label,
totalBounty = o.num("totalBounty") ?: 0.0,
tasksCompleted = (o.num("tasksCompleted") ?: 0.0).roundToInt(),
)
}
}
private data class SeriesPoint(val bucket: String, val amount: Double)
private fun parseSeries(arr: JsonArray?): List<SeriesPoint> {
if (arr == null) return emptyList()
return arr.mapNotNull { el ->
if (!el.isJsonObject) return@mapNotNull null
val o = el.asJsonObject
val bucket = o.str("bucket") ?: o.str("label") ?: o.str("date") ?: ""
val amount = o.num("amount") ?: o.num("totalBounty") ?: o.num("count") ?: 0.0
SeriesPoint(bucket, amount)
}
}
// ---------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------
@Composable
fun ConsultantMetricsScreen() {
val container = LocalAppContainer.current
val vm: ConsultantMetricsViewModel =
viewModel(factory = containerVMFactory(container) { ConsultantMetricsViewModel(it) })
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
val data = vm.data
if (data == null) {
EmptyState(
title = "No metrics yet",
subtitle = "Aggregated bounty and throughput numbers appear once your projects have activity.",
icon = Icons.Outlined.QueryStats,
)
return@ScreenState
}
// Collect numeric top-level fields (known first, then any extras), skipping
// structural arrays / objects.
val extraKeys = data.keySet().filter { it !in KNOWN_KEYS && data.num(it) != null }
val statKeys = KNOWN_KEYS.filter { data.num(it) != null } + extraKeys
val stats = statKeys.map { statFor(it, data.num(it)!!) }
val byDeveloper = parseBreakdowns(data.arr("byDeveloper"))
val byCustomer = parseBreakdowns(data.arr("byCustomer"))
val series = parseSeries(data.arr("series"))
if (stats.isEmpty() && byDeveloper.isEmpty() && byCustomer.isEmpty() && series.isEmpty()) {
EmptyState(
title = "No metrics yet",
subtitle = "Aggregated bounty and throughput numbers appear once your projects have activity.",
icon = Icons.Outlined.QueryStats,
)
return@ScreenState
}
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
data.num("totalBounty")?.let { Hero(it) }
// Drop totalBounty from the grid since it's in the hero.
val gridStats = stats.filterNot { it.label == "Total bounty" }
if (gridStats.isNotEmpty()) {
StatGrid(gridStats)
}
if (series.isNotEmpty()) {
SectionLabel("Over time")
BarChart(series)
}
if (byDeveloper.isNotEmpty()) {
SectionLabel("By developer")
BreakdownList(byDeveloper)
}
if (byCustomer.isNotEmpty()) {
SectionLabel("By project")
BreakdownList(byCustomer)
}
}
}
}
@Composable
private fun Hero(totalBounty: Double) {
val p = BB.colors
BBCard(modifier = Modifier.fillMaxWidth(), topStrip = true) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
SectionLabel("Total bounty awarded")
Text(
"${formatBounty(totalBounty)}",
fontFamily = Fraunces,
fontWeight = FontWeight.Bold,
color = p.accent,
style = MaterialTheme.typography.displayLarge,
)
}
}
}
@Composable
private fun StatGrid(stats: List<Stat>) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
stats.chunked(2).forEach { row ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
row.forEach { s ->
StatCard(s, modifier = Modifier.weight(1f))
}
if (row.size == 1) Box(Modifier.weight(1f))
}
}
}
}
@Composable
private fun StatCard(stat: Stat, modifier: Modifier = Modifier) {
val p = BB.colors
BBCard(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
SectionLabel(stat.label)
Text(
stat.value,
fontFamily = Fraunces,
fontWeight = FontWeight.Bold,
color = p.text,
style = MaterialTheme.typography.headlineMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun BreakdownList(items: List<Breakdown>) {
val p = BB.colors
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items.forEachIndexed { i, b ->
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
b.label,
style = MaterialTheme.typography.bodyMedium,
color = p.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"${b.tasksCompleted} done",
style = MaterialTheme.typography.labelSmall,
color = p.muted,
)
Text(
"${formatBounty(b.totalBounty)}",
fontFamily = SplineMono,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyMedium,
color = p.accent,
)
}
}
if (i < items.lastIndex) Hairline()
}
}
}
}
@Composable
private fun BarChart(series: List<SeriesPoint>) {
val p = BB.colors
val max = series.maxOfOrNull { it.amount }?.takeIf { it > 0 } ?: 1.0
BBCard(modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().height(140.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
series.takeLast(16).forEach { pt ->
val frac = (pt.amount / max).toFloat().coerceIn(0.02f, 1f)
Column(
Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
Modifier.fillMaxWidth().weight(1f),
contentAlignment = Alignment.BottomCenter,
) {
Box(
Modifier
.fillMaxWidth()
.fillMaxHeight(frac)
.clip(RoundedCornerShape(p.radius.dp))
.background(p.accent)
.border(1.dp, p.border, RoundedCornerShape(p.radius.dp)),
)
}
Text(
pt.bucket.takeLast(5),
style = MaterialTheme.typography.labelSmall,
color = p.muted,
maxLines = 1,
textAlign = TextAlign.Center,
)
}
}
}
}
}
class ConsultantMetricsViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var data by mutableStateOf<JsonObject?>(null)
private set
init {
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
data = container.repo.call { consultantMetrics() }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
}
@@ -0,0 +1,193 @@
package com.anypreta.bountyboard.ui.consultant
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.PoolDeveloper
import com.anypreta.bountyboard.data.net.PoolAddBody
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBAvatar
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.BBTextField
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.theme.BB
import kotlinx.coroutines.launch
@Composable
fun PoolScreen(onOpenProfile: (String) -> Unit) {
val container = LocalAppContainer.current
val vm: PoolViewModel =
viewModel(factory = containerVMFactory(container) { PoolViewModel(it) })
Column(Modifier.fillMaxWidth()) {
BBTextField(
value = vm.query,
onValueChange = { vm.query = it },
label = "Search developers",
placeholder = "Name or email…",
leadingIcon = Icons.Outlined.Search,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
val filtered = vm.filtered()
if (filtered.isEmpty()) {
EmptyState(
title = if (vm.query.isBlank()) "No developers" else "No matches",
subtitle = if (vm.query.isBlank()) {
"Developers you add to your pool can see and claim your published tasks."
} else {
"No developers match \"${vm.query}\"."
},
icon = Icons.Outlined.Groups,
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(filtered, key = { it.id }) { dev ->
DeveloperRow(
dev = dev,
onOpenProfile = { onOpenProfile(dev.id) },
onToggle = { if (dev.inPool) vm.remove(dev.id) else vm.add(dev.id) },
)
}
}
}
}
}
}
@Composable
private fun DeveloperRow(
dev: PoolDeveloper,
onOpenProfile: () -> Unit,
onToggle: () -> Unit,
) {
val p = BB.colors
BBCard(modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.weight(1f).clickable(onClick = onOpenProfile),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
BBAvatar(dev.name, dev.avatarFileId, size = 40.dp)
Column(Modifier.weight(1f)) {
Text(
dev.name.ifBlank { dev.email },
style = MaterialTheme.typography.titleSmall,
color = p.text,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
dev.email,
style = MaterialTheme.typography.bodySmall,
color = p.muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (dev.inPool) {
BBButton("Remove", onClick = onToggle, style = BBButtonStyle.Ghost, small = true)
} else {
BBButton("Add to pool", onClick = onToggle, style = BBButtonStyle.Primary, small = true)
}
}
}
}
class PoolViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var developers by mutableStateOf<List<PoolDeveloper>>(emptyList())
private set
var query by mutableStateOf("")
init {
load()
}
fun filtered(): List<PoolDeveloper> {
val q = query.trim().lowercase()
if (q.isEmpty()) return developers
return developers.filter {
it.name.lowercase().contains(q) || it.email.lowercase().contains(q)
}
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
developers = container.repo.call { pool() }.developers
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
fun add(id: String) {
viewModelScope.launch {
try {
container.repo.action { addToPool(PoolAddBody(id)) }
load()
} catch (e: Exception) {
error = e.message
}
}
}
fun remove(id: String) {
viewModelScope.launch {
try {
container.repo.action { removeFromPool(id) }
load()
} catch (e: Exception) {
error = e.message
}
}
}
}
@@ -0,0 +1,98 @@
package com.anypreta.bountyboard.ui.consultant
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.RateReview
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.Task
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.TaskCard
import com.anypreta.bountyboard.ui.theme.BB
import kotlinx.coroutines.launch
@Composable
fun ReviewsScreen(onOpenTask: (String) -> Unit) {
val container = LocalAppContainer.current
val vm: ReviewsViewModel =
viewModel(factory = containerVMFactory(container) { ReviewsViewModel(it) })
val p = BB.colors
Column(Modifier.fillMaxWidth()) {
Text(
"Tasks awaiting your review",
style = MaterialTheme.typography.bodySmall,
color = p.muted,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
if (vm.tasks.isEmpty()) {
EmptyState(
title = "Nothing to review",
subtitle = "Tasks submitted by developers for review will appear here.",
icon = Icons.Outlined.RateReview,
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
items(vm.tasks, key = { it.id }) { task ->
TaskCard(
task = task,
onClick = { onOpenTask(task.id) },
showStatus = true,
)
}
}
}
}
}
}
class ReviewsViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var tasks by mutableStateOf<List<Task>>(emptyList())
private set
init {
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
tasks = container.repo.call { reviews() }.tasks
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
}
@@ -0,0 +1,141 @@
package com.anypreta.bountyboard.ui.developer
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Sell
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBBadge
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBTextField
import com.anypreta.bountyboard.ui.components.ClaimDialog
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.FilterPill
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.TaskCard
import com.anypreta.bountyboard.ui.theme.BB
@Composable
fun BoardScreen(onOpenTask: (String) -> Unit) {
val container = LocalAppContainer.current
val vm: BoardViewModel = viewModel(factory = containerVMFactory(container) { BoardViewModel(it) })
val me by container.session.user.collectAsStateWithLifecycle()
val p = BB.colors
var customerMenu by remember { mutableStateOf(false) }
var claimFor by remember { mutableStateOf<String?>(null) }
Column(Modifier.fillMaxWidth()) {
// Search + filters
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
BBTextField(
value = vm.query,
onValueChange = { vm.query = it },
label = "Search the board",
placeholder = "Title or description…",
leadingIcon = Icons.Outlined.Search,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box {
FilterPill(
text = vm.customerId?.let { vm.customerName(it) } ?: "All projects",
icon = Icons.Outlined.FilterList,
onClick = { customerMenu = true },
)
DropdownMenu(expanded = customerMenu, onDismissRequest = { customerMenu = false }) {
DropdownMenuItem(text = { Text("All projects") }, onClick = { vm.setCustomer(null); customerMenu = false })
vm.customers.forEach { c ->
DropdownMenuItem(text = { Text(c.name) }, onClick = { vm.setCustomer(c.id); customerMenu = false })
}
}
}
FilterPill(
text = if (vm.sortByBounty) "Highest bounty" else "Newest",
icon = Icons.Outlined.Sell,
onClick = { vm.setSort(!vm.sortByBounty) },
)
BBButton("Search", onClick = { vm.load() }, style = BBButtonStyle.Primary, small = true)
}
}
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
if (vm.tasks.isEmpty()) {
EmptyState(
title = "No bounties available",
subtitle = "Published tasks from consultants who've added you to their pool appear here.",
icon = Icons.Outlined.Storefront,
)
} else {
LazyColumn(
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
items(vm.tasks, key = { it.id }) { task ->
val myId = me?.id
val claimed = task.claimRequests?.any { it.developerId == myId } == true
TaskCard(
task = task,
onClick = { onOpenTask(task.id) },
customerName = vm.customerName(task.customerId),
showAge = true,
footer = {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
val others = (task.claimRequests?.size ?: 0)
if (others > 0) {
Text(
"$others claim${if (others == 1) "" else "s"}",
style = MaterialTheme.typography.labelSmall,
color = p.muted,
)
} else {
androidx.compose.foundation.layout.Spacer(Modifier)
}
if (claimed) {
BBBadge("Requested by you", color = p.ok)
} else {
BBButton("Request assignment", onClick = { claimFor = task.id }, style = BBButtonStyle.Primary, small = true)
}
}
},
)
}
}
}
}
}
claimFor?.let { taskId ->
ClaimDialog(
onDismiss = { claimFor = null },
onConfirm = { note ->
vm.claim(taskId, note) { claimFor = null }
},
)
}
}
@@ -0,0 +1,80 @@
package com.anypreta.bountyboard.ui.developer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.CustomerRef
import com.anypreta.bountyboard.data.model.Task
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.launch
class BoardViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var tasks by mutableStateOf<List<Task>>(emptyList())
private set
var customers by mutableStateOf<List<CustomerRef>>(emptyList())
private set
// Filters
var query by mutableStateOf("")
var customerId by mutableStateOf<String?>(null)
private set
var minBounty by mutableStateOf("")
var sortByBounty by mutableStateOf(false)
private set
init {
load()
}
fun setCustomer(id: String?) {
customerId = id; load()
}
fun setSort(byBounty: Boolean) {
sortByBounty = byBounty; load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
val min = minBounty.toDoubleOrNull()
val r = container.repo.call {
board(
customerId = customerId,
q = query.ifBlank { null },
minBounty = min,
sort = if (sortByBounty) "bounty" else null,
)
}
tasks = r.tasks
customers = r.customers
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
fun claim(taskId: String, note: String?, onDone: (String?) -> Unit) {
viewModelScope.launch {
try {
container.repo.action { claim(taskId, com.anypreta.bountyboard.data.net.ClaimBody(note?.ifBlank { null })) }
load()
onDone(null)
} catch (e: Exception) {
onDone(e.message)
}
}
}
fun customerName(id: String): String? = customers.firstOrNull { it.id == id }?.name
}
@@ -0,0 +1,337 @@
package com.anypreta.bountyboard.ui.developer
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Analytics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.BountyChip
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.Kicker
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.theme.Fraunces
import com.anypreta.bountyboard.ui.theme.SplineMono
import com.anypreta.bountyboard.ui.util.formatMinutes
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
class DeveloperMetricsViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var metrics by mutableStateOf<JsonObject?>(null)
private set
init {
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
metrics = container.repo.call { developerMetrics() }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
}
// ---- Defensive JSON helpers ----
private fun JsonObject.numberOrNull(key: String): Double? =
get(key)?.takeIf { it.isJsonPrimitive && (it.asJsonPrimitive.isNumber || it.asJsonPrimitive.asString.toDoubleOrNull() != null) }
?.let { runCatching { it.asDouble }.getOrNull() }
private fun JsonObject.arrayOrNull(key: String): JsonArray? =
get(key)?.takeIf { it.isJsonArray }?.asJsonArray
private fun JsonObject.stringOrNull(key: String): String? =
get(key)?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString }.getOrNull() }
private fun humanize(key: String): String {
val spaced = key
.replace(Regex("([a-z])([A-Z])"), "$1 $2")
.replace('_', ' ')
.trim()
return spaced.replaceFirstChar { it.uppercase() }
}
private data class StatCard(val label: String, val value: String)
@Composable
fun DeveloperMetricsScreen() {
val container = LocalAppContainer.current
val vm: DeveloperMetricsViewModel = viewModel(factory = containerVMFactory(container) { DeveloperMetricsViewModel(it) })
val p = BB.colors
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
val obj = vm.metrics
if (obj == null || obj.entrySet().isEmpty()) {
EmptyState(
title = "No metrics yet",
subtitle = "Complete a few bounties and your earnings and stats will show up here.",
icon = Icons.Outlined.Analytics,
)
return@ScreenState
}
// Known fields
val totalBounty = obj.numberOrNull("totalBounty")
val knownKeys = mutableSetOf("totalBounty", "series", "byCustomer")
val cards = mutableListOf<StatCard>()
fun addKnown(key: String, label: String, format: (Double) -> String) {
obj.numberOrNull(key)?.let { cards.add(StatCard(label, format(it))); knownKeys.add(key) }
knownKeys.add(key)
}
addKnown("tasksCompleted", "Tasks completed") { it.roundToInt().toString() }
addKnown("approvalRate", "Approval rate") { rate ->
val pct = if (rate <= 1.0) rate * 100 else rate
"${pct.roundToInt()}%"
}
(obj.numberOrNull("avgLeadTimeHours") ?: obj.numberOrNull("avgLeadTime"))?.let {
cards.add(StatCard("Avg lead time", formatHours(it)))
}
knownKeys.add("avgLeadTimeHours"); knownKeys.add("avgLeadTime")
obj.numberOrNull("timeLoggedMinutes")?.let {
cards.add(StatCard("Time logged", formatMinutes(it.roundToInt())))
}
knownKeys.add("timeLoggedMinutes")
addKnown("boardDepth", "Board depth") { it.roundToInt().toString() }
// Any other top-level numeric fields
obj.entrySet().forEach { (key, value) ->
if (key in knownKeys) return@forEach
if (value != null && value.isJsonPrimitive && value.asJsonPrimitive.isNumber) {
val n = runCatching { value.asDouble }.getOrNull() ?: return@forEach
val rendered = if (n == n.roundToInt().toDouble()) n.roundToInt().toString()
else String.format(java.util.Locale.US, "%.2f", n)
cards.add(StatCard(humanize(key), rendered))
}
}
val series = parseSeries(obj.arrayOrNull("series"))
val byCustomer = parseByCustomer(obj.arrayOrNull("byCustomer"))
val nothing = totalBounty == null && cards.isEmpty() && series.isEmpty() && byCustomer.isEmpty()
if (nothing) {
EmptyState(
title = "No metrics yet",
subtitle = "Complete a few bounties and your earnings and stats will show up here.",
icon = Icons.Outlined.Analytics,
)
return@ScreenState
}
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
// Hero
item(key = "hero") {
BBCard(modifier = Modifier.fillMaxWidth(), topStrip = true) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Kicker("Total bounty earned")
BountyChip(totalBounty ?: 0.0, large = true)
}
}
}
// Stat grid (2 columns)
if (cards.isNotEmpty()) {
val rows = cards.chunked(2).withIndex().toList()
items(rows, key = { "row-${it.index}" }) { (_, rowCards) ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
rowCards.forEach { card ->
StatCardView(card, Modifier.weight(1f))
}
if (rowCards.size == 1) Spacer(Modifier.weight(1f))
}
}
}
// Bar chart
if (series.isNotEmpty()) {
item(key = "series") {
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Kicker("Earnings over time")
BarChart(series)
}
}
}
}
// By customer
if (byCustomer.isNotEmpty()) {
item(key = "byCustomer") {
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Kicker("By project")
byCustomer.forEach { row ->
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
row.label.ifBlank { "" },
style = MaterialTheme.typography.bodyMedium,
color = p.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
BountyChip(row.totalBounty)
}
}
}
}
}
}
}
}
}
@Composable
private fun StatCardView(card: StatCard, modifier: Modifier = Modifier) {
val p = BB.colors
BBCard(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
card.value,
fontFamily = Fraunces,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.displayMedium,
color = p.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
card.label.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontFamily = SplineMono,
color = p.muted,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun BarChart(series: List<SeriesPoint>) {
val p = BB.colors
val max = series.maxOf { it.amount }.coerceAtLeast(0.0001)
val maxHeight = 120.dp
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.Bottom,
) {
series.forEach { point ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
val frac = (point.amount / max).toFloat().coerceIn(0f, 1f)
val barHeight = (maxHeight * frac).coerceAtLeast(4.dp)
Box(
Modifier
.width(28.dp)
.height(barHeight)
.clip(RoundedCornerShape((p.radius).dp))
.background(p.accent),
)
Text(
point.bucket,
style = MaterialTheme.typography.labelSmall,
fontFamily = SplineMono,
color = p.muted,
textAlign = TextAlign.Center,
maxLines = 1,
modifier = Modifier.width(44.dp),
)
}
}
}
}
// ---- Parsing of nested shapes ----
private data class SeriesPoint(val bucket: String, val amount: Double, val count: Int)
private data class CustomerRow(val label: String, val totalBounty: Double)
private fun parseSeries(arr: JsonArray?): List<SeriesPoint> {
if (arr == null) return emptyList()
val out = mutableListOf<SeriesPoint>()
arr.forEach { el ->
val o = el.takeIf { it.isJsonObject }?.asJsonObject ?: return@forEach
val bucket = o.stringOrNull("bucket") ?: o.stringOrNull("week") ?: ""
val amount = o.numberOrNull("amount") ?: o.numberOrNull("total") ?: 0.0
val count = o.numberOrNull("count")?.roundToInt() ?: 0
out.add(SeriesPoint(bucket, amount, count))
}
return out
}
private fun parseByCustomer(arr: JsonArray?): List<CustomerRow> {
if (arr == null) return emptyList()
val out = mutableListOf<CustomerRow>()
arr.forEach { el ->
val o = el.takeIf { it.isJsonObject }?.asJsonObject ?: return@forEach
val label = o.stringOrNull("label") ?: o.stringOrNull("key") ?: ""
val total = o.numberOrNull("totalBounty") ?: 0.0
out.add(CustomerRow(label, total))
}
return out
}
private fun formatHours(hours: Double): String {
if (hours <= 0) return "0h"
return if (hours == hours.roundToInt().toDouble()) "${hours.roundToInt()}h"
else String.format(java.util.Locale.US, "%.1fh", hours)
}
@@ -0,0 +1,136 @@
package com.anypreta.bountyboard.ui.developer
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.LeaderboardEntry
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBAvatar
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.BountyChip
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.theme.Fraunces
import kotlinx.coroutines.launch
class LeaderboardViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var entries by mutableStateOf<List<LeaderboardEntry>>(emptyList())
private set
init {
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
val r = container.repo.call { leaderboard() }
entries = r.items()
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
}
@Composable
fun LeaderboardScreen(onOpenProfile: (String) -> Unit) {
val container = LocalAppContainer.current
val vm: LeaderboardViewModel = viewModel(factory = containerVMFactory(container) { LeaderboardViewModel(it) })
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
if (vm.entries.isEmpty()) {
EmptyState(
title = "No rankings yet",
subtitle = "Once developers start completing bounties, the leaderboard will fill in here.",
icon = Icons.Outlined.EmojiEvents,
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
itemsIndexed(vm.entries, key = { _, e -> e.developerId.ifEmpty { e.name } }) { index, entry ->
LeaderboardRow(
entry = entry,
rank = if (entry.rank > 0) entry.rank else index + 1,
onClick = { onOpenProfile(entry.developerId) },
)
}
}
}
}
}
@Composable
private fun LeaderboardRow(entry: LeaderboardEntry, rank: Int, onClick: () -> Unit) {
val p = BB.colors
val rankColor = if (rank in 1..3) p.accent else p.muted
BBCard(modifier = Modifier.fillMaxWidth(), onClick = onClick) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(Modifier.width(40.dp), contentAlignment = Alignment.Center) {
Text(
rank.toString(),
fontFamily = Fraunces,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.headlineMedium,
color = rankColor,
)
}
BBAvatar(name = entry.name, fileId = entry.avatarFileId, size = 40.dp)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
entry.name.ifBlank { "Unknown" },
style = MaterialTheme.typography.titleMedium,
color = p.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"${entry.tasksCompleted} task${if (entry.tasksCompleted == 1) "" else "s"}",
style = MaterialTheme.typography.labelSmall,
color = p.muted,
)
}
BountyChip(entry.totalBounty)
}
}
}
@@ -0,0 +1,134 @@
package com.anypreta.bountyboard.ui.developer
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Assignment
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.Task
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.TaskCard
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.theme.SplineMono
import kotlinx.coroutines.launch
class MyTasksViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var tasks by mutableStateOf<List<Task>>(emptyList())
private set
init {
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
val r = container.repo.call { myTasks() }
tasks = r.tasks
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
}
private data class KanbanSection(val title: String, val match: (String) -> Boolean)
private val kanbanSections = listOf(
KanbanSection("Assigned") { it == "assigned" },
KanbanSection("In progress") { it == "in_progress" || it == "changes_requested" },
KanbanSection("In review") { it == "in_review" },
KanbanSection("Done") { it == "approved" },
)
@Composable
fun MyTasksScreen(onOpenTask: (String) -> Unit) {
val container = LocalAppContainer.current
val vm: MyTasksViewModel = viewModel(factory = containerVMFactory(container) { MyTasksViewModel(it) })
val p = BB.colors
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
if (vm.tasks.isEmpty()) {
EmptyState(
title = "Nothing on your plate",
subtitle = "Tasks you've been assigned or are working on will appear here, grouped by status.",
icon = Icons.Outlined.Assignment,
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
kanbanSections.forEach { section ->
val sectionTasks = vm.tasks.filter { section.match(it.status) }
item(key = "header-${section.title}") {
Row(
Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
section.title.uppercase(),
style = MaterialTheme.typography.labelMedium,
fontFamily = SplineMono,
color = p.muted,
)
Text(
sectionTasks.size.toString(),
style = MaterialTheme.typography.labelMedium,
fontFamily = SplineMono,
color = p.accent,
)
}
}
if (sectionTasks.isEmpty()) {
item(key = "empty-${section.title}") {
Text(
"",
style = MaterialTheme.typography.bodyMedium,
color = p.muted,
modifier = Modifier.padding(start = 4.dp, bottom = 4.dp),
)
}
} else {
items(sectionTasks, key = { it.id }) { task ->
TaskCard(
task = task,
onClick = { onOpenTask(task.id) },
showStatus = true,
)
}
}
}
}
}
}
}
@@ -0,0 +1,445 @@
package com.anypreta.bountyboard.ui.messaging
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import android.net.Uri
import android.provider.OpenableColumns
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.InsertDriveFile
import androidx.compose.material.icons.outlined.Send
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.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.Message
import com.anypreta.bountyboard.data.model.MessageAttachment
import com.anypreta.bountyboard.data.net.SendMessageBody
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBAvatar
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.util.clockTime
import com.anypreta.bountyboard.ui.util.htmlToPlain
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
class ConversationViewModel(
private val container: AppContainer,
private val conversationId: String,
) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var sending by mutableStateOf(false)
private set
var messages by mutableStateOf<List<Message>>(emptyList())
private set
private val nameCache = mutableMapOf<String, String>()
var names by mutableStateOf<Map<String, String>>(emptyMap())
private set
init {
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
val r = container.repo.call { messages(conversationId, cursor = null, limit = 50) }
messages = r.messages.sortedBy { it.id }
resolveNames(messages.map { it.senderId }.toSet())
markRead()
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
private fun resolveNames(ids: Set<String>) {
val missing = ids.filter { it.isNotBlank() && !nameCache.containsKey(it) }
if (missing.isEmpty()) return
viewModelScope.launch {
missing.forEach { id ->
try {
val card = container.repo.call { userCard(id) }.card
nameCache[id] = card.name.ifBlank { "User" }
} catch (_: Exception) {
nameCache[id] = "User"
}
}
names = nameCache.toMap()
}
}
fun nameFor(id: String): String = names[id] ?: "User"
fun markRead() {
viewModelScope.launch {
try {
container.repo.call { markConversationRead(conversationId) }
} catch (_: Exception) {
}
}
}
fun uploadFile(bytes: ByteArray, name: String, mime: String, onDone: (MessageAttachment?) -> Unit) {
viewModelScope.launch {
try {
val body = bytes.toRequestBody(mime.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", name, body)
val r = container.repo.call { uploadFile(part, null) }
onDone(
MessageAttachment(
fileId = r.fileId,
name = r.name,
mimeType = r.mimeType,
size = r.size,
isImage = r.isImage,
),
)
} catch (e: Exception) {
error = e.message
onDone(null)
}
}
}
fun send(text: String, attachments: List<String>, onSent: () -> Unit) {
if (text.isBlank() && attachments.isEmpty()) return
sending = true
viewModelScope.launch {
try {
container.repo.call {
sendMessage(conversationId, SendMessageBody(body = text.trim(), attachments = attachments))
}
val r = container.repo.call { messages(conversationId, cursor = null, limit = 50) }
messages = r.messages.sortedBy { it.id }
resolveNames(messages.map { it.senderId }.toSet())
onSent()
} catch (e: Exception) {
error = e.message
} finally {
sending = false
}
}
}
}
@Composable
fun ConversationScreen(conversationId: String, onOpenProfile: (String) -> Unit) {
val container = LocalAppContainer.current
val vm: ConversationViewModel = viewModel(
key = "conv_$conversationId",
factory = containerVMFactory(container) { ConversationViewModel(it, conversationId) },
)
val me by container.session.user.collectAsStateWithLifecycle()
val p = BB.colors
val ctx = LocalContext.current
val listState = rememberLazyListState()
var input by remember { mutableStateOf("") }
val pending = remember { mutableStateListOf<MessageAttachment>() }
var uploading by remember { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) {
val bytes = ctx.contentResolver.openInputStream(uri)?.use { it.readBytes() }
if (bytes != null) {
val name = queryName(ctx, uri)
val mime = ctx.contentResolver.getType(uri) ?: "application/octet-stream"
uploading = true
vm.uploadFile(bytes, name, mime) { att ->
uploading = false
if (att != null) pending.add(att)
}
}
}
}
LaunchedEffect(vm.messages.size) {
if (vm.messages.isNotEmpty()) {
listState.animateScrollToItem(vm.messages.size - 1)
}
}
Column(Modifier.fillMaxSize()) {
Box(Modifier.weight(1f).fillMaxWidth()) {
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(vm.messages, key = { it.id }) { msg ->
MessageBubble(
message = msg,
mine = msg.senderId == me?.id,
senderName = vm.nameFor(msg.senderId),
onOpenProfile = onOpenProfile,
fileUrl = { container.fileUrl(it) },
)
}
}
}
}
InputBar(
value = input,
onValueChange = { input = it },
pending = pending,
uploading = uploading,
sending = vm.sending,
onAttach = { launcher.launch("*/*") },
onRemoveAttachment = { pending.remove(it) },
onSend = {
vm.send(input, pending.map { it.fileId }) {
input = ""
pending.clear()
}
},
)
}
}
@Composable
private fun MessageBubble(
message: Message,
mine: Boolean,
senderName: String,
onOpenProfile: (String) -> Unit,
fileUrl: (String) -> String,
) {
val p = BB.colors
val uriHandler = LocalUriHandler.current
val ctx = LocalContext.current
val container = LocalAppContainer.current
val shape = RoundedCornerShape(p.radius.dp)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start,
) {
Column(
modifier = Modifier.widthIn(max = 280.dp),
horizontalAlignment = if (mine) Alignment.End else Alignment.Start,
) {
if (!mine) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.clickable { onOpenProfile(message.senderId) }.padding(bottom = 3.dp),
) {
BBAvatar(senderName, null, size = 22.dp)
Text(senderName, style = MaterialTheme.typography.labelSmall, color = p.muted)
}
}
Box(
Modifier
.clip(shape)
.background(if (mine) p.accent else p.surface2)
.border(1.dp, p.border, shape)
.padding(10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
message.attachments?.forEach { att ->
if (att.isImage) {
AsyncImage(
model = ImageRequest.Builder(ctx).data(fileUrl(att.fileId)).build(),
imageLoader = container.imageLoader,
contentDescription = att.name,
modifier = Modifier
.widthIn(max = 200.dp)
.size(200.dp)
.clip(RoundedCornerShape(2.dp))
.clickable { uriHandler.openUri(fileUrl(att.fileId)) },
)
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.clickable { uriHandler.openUri(fileUrl(att.fileId)) },
) {
Icon(
Icons.Outlined.InsertDriveFile,
null,
tint = if (mine) p.onAccent else p.accent,
modifier = Modifier.size(16.dp),
)
Text(
att.name ?: "Attachment",
style = MaterialTheme.typography.bodySmall,
color = if (mine) p.onAccent else p.accent,
)
}
}
}
if (message.body.isNotBlank()) {
Text(
htmlToPlain(message.body),
style = MaterialTheme.typography.bodyMedium,
color = if (mine) p.onAccent else p.text,
)
}
}
}
message.editedAt?.let {
Text(
"edited ${clockTime(it)}",
style = MaterialTheme.typography.labelSmall,
color = p.muted,
modifier = Modifier.padding(top = 2.dp, start = 2.dp, end = 2.dp),
)
}
}
}
}
@Composable
private fun InputBar(
value: String,
onValueChange: (String) -> Unit,
pending: List<MessageAttachment>,
uploading: Boolean,
sending: Boolean,
onAttach: () -> Unit,
onRemoveAttachment: (MessageAttachment) -> Unit,
onSend: () -> Unit,
) {
val p = BB.colors
Column(
Modifier
.fillMaxWidth()
.background(p.surface)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (pending.isNotEmpty() || uploading) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
pending.forEach { att ->
Row(
Modifier
.clip(RoundedCornerShape(p.radius.dp))
.background(p.surface2)
.border(1.dp, p.border, RoundedCornerShape(p.radius.dp))
.padding(start = 8.dp, end = 4.dp, top = 3.dp, bottom = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(att.name ?: "file", style = MaterialTheme.typography.labelSmall, color = p.text)
Icon(
Icons.Outlined.Close,
"Remove",
tint = p.muted,
modifier = Modifier.size(14.dp).clickable { onRemoveAttachment(att) },
)
}
}
if (uploading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = p.accent)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(onClick = onAttach) {
Icon(Icons.Outlined.AttachFile, "Attach", tint = p.muted)
}
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
placeholder = { Text("Message…", color = p.muted, style = MaterialTheme.typography.bodyMedium) },
maxLines = 4,
shape = RoundedCornerShape(p.radius.dp),
textStyle = MaterialTheme.typography.bodyLarge,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = p.accent,
unfocusedBorderColor = p.border,
focusedContainerColor = p.bg,
unfocusedContainerColor = p.bg,
cursorColor = p.accent,
focusedTextColor = p.text,
unfocusedTextColor = p.text,
),
)
val canSend = (value.isNotBlank() || pending.isNotEmpty()) && !sending
IconButton(onClick = onSend, enabled = canSend) {
if (sending) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = p.accent)
} else {
Icon(Icons.Outlined.Send, "Send", tint = if (canSend) p.accent else p.muted)
}
}
}
}
}
private fun queryName(ctx: android.content.Context, uri: Uri): String {
var name = "upload"
try {
ctx.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && cursor.moveToFirst()) {
cursor.getString(idx)?.let { if (it.isNotBlank()) name = it }
}
}
} catch (_: Exception) {
}
return name
}
@@ -0,0 +1,252 @@
package com.anypreta.bountyboard.ui.messaging
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.ConversationSummary
import com.anypreta.bountyboard.data.model.User
import com.anypreta.bountyboard.data.net.CreateConversationBody
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBAvatar
import com.anypreta.bountyboard.ui.components.BBBadge
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.BBTextField
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.util.relativeTime
import kotlinx.coroutines.launch
class MessagesViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var conversations by mutableStateOf<List<ConversationSummary>>(emptyList())
private set
var searchResults by mutableStateOf<List<User>>(emptyList())
private set
var searching by mutableStateOf(false)
private set
init {
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
conversations = container.repo.call { conversations() }.conversations
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
fun search(q: String) {
if (q.isBlank()) {
searchResults = emptyList()
return
}
searching = true
viewModelScope.launch {
try {
searchResults = container.repo.call { users(q.trim()) }.users
} catch (_: Exception) {
searchResults = emptyList()
} finally {
searching = false
}
}
}
fun startDm(userId: String, onDone: (String) -> Unit) {
viewModelScope.launch {
try {
val r = container.repo.call {
createConversation(CreateConversationBody(kind = "dm", participantIds = listOf(userId)))
}
onDone(r.conversation.id)
} catch (e: Exception) {
error = e.message
}
}
}
}
@Composable
fun MessagesScreen(onOpenConversation: (String) -> Unit) {
val container = LocalAppContainer.current
val vm: MessagesViewModel = viewModel(factory = containerVMFactory(container) { MessagesViewModel(it) })
val p = BB.colors
var showNew by remember { mutableStateOf(false) }
Column(Modifier.fillMaxWidth()) {
Box(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
BBButton(
"New message",
onClick = { showNew = true },
style = BBButtonStyle.Primary,
modifier = Modifier.fillMaxWidth(),
)
}
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
if (vm.conversations.isEmpty()) {
EmptyState(
title = "No conversations yet",
subtitle = "Start a direct message to coordinate on tasks.",
icon = Icons.Outlined.Forum,
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(vm.conversations, key = { it.id }) { conv ->
ConversationRow(conv) { onOpenConversation(conv.id) }
}
}
}
}
}
if (showNew) {
NewMessageDialog(
vm = vm,
onDismiss = { showNew = false },
onPicked = { userId ->
vm.startDm(userId) { convId ->
showNew = false
onOpenConversation(convId)
}
},
)
}
}
@Composable
private fun ConversationRow(conv: ConversationSummary, onClick: () -> Unit) {
val p = BB.colors
val title = conv.title ?: "Conversation"
BBCard(modifier = Modifier.fillMaxWidth(), onClick = onClick) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
BBAvatar(title, null, size = 40.dp)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = p.text,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (conv.kind != "dm") BBBadge(conv.kind, color = p.accent)
}
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(relativeTime(conv.lastMessageAt), style = MaterialTheme.typography.labelSmall, color = p.muted)
if (conv.unread > 0) {
BBBadge(conv.unread.toString(), color = p.err, filled = true)
}
}
}
}
}
@Composable
private fun NewMessageDialog(
vm: MessagesViewModel,
onDismiss: () -> Unit,
onPicked: (String) -> Unit,
) {
val p = BB.colors
var query by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = p.surface,
title = { Text("New message", style = MaterialTheme.typography.headlineSmall, color = p.text) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
BBTextField(
value = query,
onValueChange = { query = it; vm.search(it) },
label = "Search people",
placeholder = "Name or email…",
leadingIcon = Icons.Outlined.Search,
modifier = Modifier.fillMaxWidth(),
)
LazyColumn(
modifier = Modifier.heightIn(max = 320.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items(vm.searchResults, key = { it.id }) { user ->
UserPickRow(user) { onPicked(user.id) }
}
}
}
},
confirmButton = {},
dismissButton = {
BBButton("Close", onClick = onDismiss, style = BBButtonStyle.Ghost, small = true)
},
)
}
@Composable
private fun UserPickRow(user: User, onClick: () -> Unit) {
val p = BB.colors
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
BBAvatar(user.name, user.avatarFileId, size = 36.dp)
Column(Modifier.weight(1f)) {
Text(user.name, style = MaterialTheme.typography.bodyMedium, color = p.text, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(user.email, style = MaterialTheme.typography.labelSmall, color = p.muted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
BBButton("Message", onClick = onClick, style = BBButtonStyle.Primary, small = true)
}
}
@@ -0,0 +1,60 @@
package com.anypreta.bountyboard.ui.nav
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.ui.Modifier
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.auth.AuthFlow
import com.anypreta.bountyboard.ui.auth.ForcedChangePasswordScreen
import com.anypreta.bountyboard.ui.components.LoadingBox
import com.anypreta.bountyboard.ui.theme.BB
import kotlinx.coroutines.launch
class RootViewModel(private val container: AppContainer) : ViewModel() {
var booted by mutableStateOf(false)
private set
init {
bootstrap()
}
fun bootstrap() {
viewModelScope.launch {
try {
val r = container.repo.call { me() }
container.session.setSession(r.user, r.mustChangePassword, r.oidcEnabled)
} catch (_: Exception) {
container.session.clear()
} finally {
booted = true
}
}
}
}
@Composable
fun AppRoot() {
val container = LocalAppContainer.current
val vm: RootViewModel = viewModel(factory = containerVMFactory(container) { RootViewModel(it) })
val user by container.session.user.collectAsStateWithLifecycle()
val mustChange by container.session.mustChangePassword.collectAsStateWithLifecycle()
Surface(color = BB.colors.bg, modifier = Modifier.fillMaxSize()) {
when {
!vm.booted -> LoadingBox()
user == null -> AuthFlow()
mustChange -> ForcedChangePasswordScreen()
else -> MainShell()
}
}
}
@@ -0,0 +1,438 @@
package com.anypreta.bountyboard.ui.nav
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.LightMode
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.admin.AdminAuditScreen
import com.anypreta.bountyboard.ui.admin.AdminCustomersScreen
import com.anypreta.bountyboard.ui.admin.AdminServiceStatusScreen
import com.anypreta.bountyboard.ui.admin.AdminSettingsScreen
import com.anypreta.bountyboard.ui.admin.AdminUsersScreen
import com.anypreta.bountyboard.ui.admin.CustomerEditScreen
import com.anypreta.bountyboard.ui.components.BBAvatar
import com.anypreta.bountyboard.ui.components.DiamondBrand
import com.anypreta.bountyboard.ui.components.Hairline
import com.anypreta.bountyboard.ui.consultant.ConsultantBoardScreen
import com.anypreta.bountyboard.ui.consultant.ConsultantMetricsScreen
import com.anypreta.bountyboard.ui.consultant.PoolScreen
import com.anypreta.bountyboard.ui.consultant.ReviewsScreen
import com.anypreta.bountyboard.ui.developer.BoardScreen
import com.anypreta.bountyboard.ui.developer.DeveloperMetricsScreen
import com.anypreta.bountyboard.ui.developer.LeaderboardScreen
import com.anypreta.bountyboard.ui.developer.MyTasksScreen
import com.anypreta.bountyboard.ui.messaging.ConversationScreen
import com.anypreta.bountyboard.ui.messaging.MessagesScreen
import com.anypreta.bountyboard.ui.profile.ProfileScreen
import com.anypreta.bountyboard.ui.profile.PublicProfileScreen
import com.anypreta.bountyboard.ui.shared.NotificationsScreen
import com.anypreta.bountyboard.ui.task.TaskDetailScreen
import com.anypreta.bountyboard.ui.theme.BB
import kotlinx.coroutines.launch
private val topLevelRoutes: Set<String> = setOf(
Routes.BOARD, Routes.MY_TASKS, Routes.DEV_METRICS, Routes.LEADERBOARD,
Routes.CONSULTANT_BOARD, Routes.REVIEWS, Routes.POOL, Routes.CONSULTANT_METRICS,
Routes.ADMIN_USERS, Routes.ADMIN_CUSTOMERS, Routes.ADMIN_SETTINGS, Routes.ADMIN_AUDIT, Routes.ADMIN_STATUS,
Routes.MESSAGES, Routes.NOTIFICATIONS, Routes.PROFILE,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainShell() {
val container = LocalAppContainer.current
val shellVm: ShellViewModel = viewModel(factory = containerVMFactory(container) { ShellViewModel(it) })
val user by container.session.user.collectAsStateWithLifecycle()
val themeId by container.settings.theme.collectAsStateWithLifecycle(initialValue = "light")
val unread by shellVm.unread.collectAsStateWithLifecycle()
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val backStack by navController.currentBackStackEntryAsState()
val currentRoute = backStack?.destination?.route ?: Routes.BOARD
val baseRoute = currentRoute.substringBefore("/")
val isTopLevel = baseRoute in topLevelRoutes
val sections = navSectionsFor(user)
val p = BB.colors
// Background push: schedule/cancel the poll as the toggle changes.
val pushEnabled by container.settings.pushEnabled.collectAsStateWithLifecycle(initialValue = true)
LaunchedEffect(pushEnabled) {
if (pushEnabled) container.schedulePush() else container.cancelPush()
}
// Deep link from a tapped system notification.
val deepLink by container.pendingDeepLink.collectAsStateWithLifecycle()
LaunchedEffect(deepLink) {
val target = deepLink ?: return@LaunchedEffect
runCatching { navController.navigate(target) }
container.consumeDeepLink()
}
fun navigateTop(route: String) {
navController.navigate(route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = isTopLevel,
drawerContent = {
ModalDrawerSheet(
drawerContainerColor = p.surface2,
modifier = Modifier.width(280.dp),
) {
DrawerContent(
sections = sections,
currentRoute = baseRoute,
onSelect = { route ->
scope.launch { drawerState.close() }
navigateTop(route)
},
onLogout = { shellVm.logout { container.cookieJar.clear() } },
)
}
},
) {
Scaffold(
containerColor = p.bg,
topBar = {
TopAppBar(
title = {
if (isTopLevel) {
Text(
titleFor(baseRoute),
fontWeight = FontWeight.SemiBold,
color = p.text,
style = MaterialTheme.typography.titleLarge,
)
} else {
Text(
titleFor(baseRoute),
fontWeight = FontWeight.SemiBold,
color = p.text,
style = MaterialTheme.typography.titleMedium,
)
}
},
navigationIcon = {
if (isTopLevel) {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Outlined.Menu, "Menu", tint = p.text)
}
} else {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, "Back", tint = p.text)
}
}
},
actions = {
IconButton(onClick = { shellVm.toggleTheme() }) {
Icon(
if (themeId == "dark") Icons.Outlined.LightMode else Icons.Outlined.DarkMode,
"Toggle theme",
tint = p.text,
)
}
IconButton(onClick = { navController.navigate(Routes.NOTIFICATIONS) }) {
BadgedBox(badge = {
if (unread > 0) {
Badge(containerColor = p.err, contentColor = Color.White) {
Text(if (unread > 99) "99+" else unread.toString())
}
}
}) {
Icon(Icons.Outlined.Notifications, "Notifications", tint = p.text)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = p.surface2,
titleContentColor = p.text,
),
)
},
) { padding ->
Box(Modifier.padding(padding)) {
MainNavHost(
navController = navController,
startDestination = homeRouteFor(user),
onUnreadChanged = { shellVm.refreshUnread() },
)
}
}
}
}
@Composable
private fun DrawerContent(
sections: List<NavSection>,
currentRoute: String,
onSelect: (String) -> Unit,
onLogout: () -> Unit,
) {
val container = LocalAppContainer.current
val user by container.session.user.collectAsStateWithLifecycle()
val p = BB.colors
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(20.dp))
DiamondBrand("Bounty Board")
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
BBAvatar(user?.name ?: "", user?.avatarFileId, size = 40.dp)
Spacer(Modifier.width(10.dp))
Column {
Text(user?.name ?: "", style = MaterialTheme.typography.titleSmall, color = p.text, fontWeight = FontWeight.Bold)
Text(user?.roleLabel() ?: "", style = MaterialTheme.typography.labelSmall, color = p.muted)
}
}
Spacer(Modifier.height(16.dp))
Hairline()
Spacer(Modifier.height(12.dp))
sections.forEach { section ->
Text(
section.title.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = p.muted,
modifier = Modifier.padding(vertical = 8.dp),
)
section.dests.forEach { dest ->
DrawerItem(dest, selected = dest.route == currentRoute, onClick = { onSelect(dest.route) })
}
Spacer(Modifier.height(8.dp))
}
Hairline()
Spacer(Modifier.height(8.dp))
DrawerItemRaw(Icons.Outlined.Logout, "Log out", selected = false, onClick = onLogout)
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun DrawerItem(dest: NavDest, selected: Boolean, onClick: () -> Unit) {
DrawerItemRaw(dest.icon, dest.label, selected, onClick)
}
@Composable
private fun DrawerItemRaw(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
val p = BB.colors
val shape = RoundedCornerShape(p.radius.dp)
Row(
Modifier
.fillMaxWidth()
.clip(shape)
.background(if (selected) p.surface else Color.Transparent)
.then(if (selected) Modifier.padding(start = 0.dp) else Modifier)
.clickableRow(onClick)
.padding(horizontal = 12.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selected) {
Box(Modifier.width(3.dp).height(20.dp).background(p.accent))
Spacer(Modifier.width(9.dp))
} else {
Spacer(Modifier.width(12.dp))
}
Icon(icon, null, tint = if (selected) p.accent else p.muted, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(12.dp))
Text(
label,
style = MaterialTheme.typography.bodyMedium,
color = if (selected) p.text else p.text.copy(alpha = 0.8f),
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
)
}
}
private fun Modifier.clickableRow(onClick: () -> Unit): Modifier =
this.clickable(onClick = onClick)
private fun titleFor(route: String): String = when (route) {
Routes.BOARD -> "Bounty Board"
Routes.MY_TASKS -> "My Tasks"
Routes.DEV_METRICS, Routes.CONSULTANT_METRICS -> "Metrics"
Routes.LEADERBOARD -> "Leaderboard"
Routes.CONSULTANT_BOARD -> "Atomization Board"
Routes.REVIEWS -> "Reviews"
Routes.POOL -> "Developer Pool"
Routes.ADMIN_USERS -> "Users"
Routes.ADMIN_CUSTOMERS -> "Projects"
Routes.ADMIN_SETTINGS -> "Settings"
Routes.ADMIN_AUDIT -> "Audit Log"
Routes.ADMIN_STATUS -> "Service Status"
Routes.MESSAGES -> "Messages"
Routes.NOTIFICATIONS -> "Notifications"
Routes.PROFILE -> "Profile"
Routes.TASK -> "Task"
Routes.CONVERSATION -> "Conversation"
Routes.CUSTOMER_EDIT -> "Project"
Routes.PUBLIC_PROFILE -> "Profile"
else -> "Bounty Board"
}
@Composable
private fun MainNavHost(
navController: androidx.navigation.NavHostController,
startDestination: String,
onUnreadChanged: () -> Unit,
) {
val openTask: (String) -> Unit = { id -> navController.navigate(Routes.task(id)) }
val openProfile: (String) -> Unit = { id -> navController.navigate(Routes.publicProfile(id)) }
val openConversation: (String) -> Unit = { id -> navController.navigate(Routes.conversation(id)) }
NavHost(navController = navController, startDestination = startDestination) {
// Developer
composable(Routes.BOARD) { BoardScreen(onOpenTask = openTask) }
composable(Routes.MY_TASKS) { MyTasksScreen(onOpenTask = openTask) }
composable(Routes.DEV_METRICS) { DeveloperMetricsScreen() }
composable(Routes.LEADERBOARD) { LeaderboardScreen(onOpenProfile = openProfile) }
// Consultant
composable(Routes.CONSULTANT_BOARD) { ConsultantBoardScreen(onOpenTask = openTask) }
composable(Routes.REVIEWS) { ReviewsScreen(onOpenTask = openTask) }
composable(Routes.POOL) { PoolScreen(onOpenProfile = openProfile) }
composable(Routes.CONSULTANT_METRICS) { ConsultantMetricsScreen() }
// Admin
composable(Routes.ADMIN_USERS) { AdminUsersScreen(onOpenProfile = openProfile) }
composable(Routes.ADMIN_CUSTOMERS) {
AdminCustomersScreen(onEditCustomer = { id -> navController.navigate(Routes.customerEdit(id)) })
}
composable(
"${Routes.CUSTOMER_EDIT}/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
) { entry ->
CustomerEditScreen(
customerId = entry.arguments?.getString("id") ?: "new",
onDone = { navController.popBackStack() },
)
}
composable(Routes.ADMIN_SETTINGS) { AdminSettingsScreen() }
composable(Routes.ADMIN_AUDIT) { AdminAuditScreen() }
composable(Routes.ADMIN_STATUS) { AdminServiceStatusScreen() }
// Shared
composable(Routes.MESSAGES) { MessagesScreen(onOpenConversation = openConversation) }
composable(
"${Routes.CONVERSATION}/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
) { entry ->
ConversationScreen(conversationId = entry.arguments?.getString("id") ?: "", onOpenProfile = openProfile)
}
composable(Routes.NOTIFICATIONS) {
NotificationsScreen(
onOpenLink = { link -> handleNotificationLink(link, openTask, navController) },
onChanged = onUnreadChanged,
)
}
composable(Routes.PROFILE) { ProfileScreen() }
composable(
"${Routes.PUBLIC_PROFILE}/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
) { entry ->
PublicProfileScreen(
userId = entry.arguments?.getString("id") ?: "",
onOpenConversation = openConversation,
)
}
// Task detail
composable(
"${Routes.TASK}/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
) { entry ->
TaskDetailScreen(
taskId = entry.arguments?.getString("id") ?: "",
onBack = { navController.popBackStack() },
onOpenProfile = openProfile,
)
}
}
}
private fun handleNotificationLink(
link: String,
openTask: (String) -> Unit,
navController: androidx.navigation.NavHostController,
) {
val clean = link.trim('/')
when {
clean.startsWith("tasks/") -> openTask(clean.removePrefix("tasks/"))
clean == "board" -> navController.navigate(Routes.BOARD)
clean.startsWith("conversations/") -> navController.navigate(Routes.conversation(clean.removePrefix("conversations/")))
else -> {}
}
}
@Suppress("unused")
private fun unusedPadding() = PaddingValues(0.dp)
@@ -0,0 +1,114 @@
package com.anypreta.bountyboard.ui.nav
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ListAlt
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material.icons.outlined.Assignment
import androidx.compose.material.icons.outlined.AutoAwesomeMosaic
import androidx.compose.material.icons.outlined.Business
import androidx.compose.material.icons.outlined.Chat
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Insights
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.RateReview
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.ui.graphics.vector.ImageVector
import com.anypreta.bountyboard.data.model.User
object Routes {
const val BOARD = "board"
const val MY_TASKS = "my_tasks"
const val DEV_METRICS = "dev_metrics"
const val LEADERBOARD = "leaderboard"
const val CONSULTANT_BOARD = "consultant_board"
const val REVIEWS = "reviews"
const val POOL = "pool"
const val CONSULTANT_METRICS = "consultant_metrics"
const val ADMIN_USERS = "admin_users"
const val ADMIN_CUSTOMERS = "admin_customers"
const val ADMIN_SETTINGS = "admin_settings"
const val ADMIN_AUDIT = "admin_audit"
const val ADMIN_STATUS = "admin_status"
const val MESSAGES = "messages"
const val NOTIFICATIONS = "notifications"
const val PROFILE = "profile"
const val TASK = "task" // task/{id}
const val CONVERSATION = "conversation" // conversation/{id}
const val CUSTOMER_EDIT = "customer_edit" // customer_edit/{id} (id=new for create)
const val PUBLIC_PROFILE = "public_profile" // public_profile/{id}
fun task(id: String) = "$TASK/$id"
fun conversation(id: String) = "$CONVERSATION/$id"
fun customerEdit(id: String) = "$CUSTOMER_EDIT/$id"
fun publicProfile(id: String) = "$PUBLIC_PROFILE/$id"
}
data class NavDest(val route: String, val label: String, val icon: ImageVector)
data class NavSection(val title: String, val dests: List<NavDest>)
/** Build the role-scoped navigation sections for the drawer. */
fun navSectionsFor(user: User?): List<NavSection> {
if (user == null) return emptyList()
val sections = mutableListOf<NavSection>()
val roles = user.roles
if (roles.developer) {
sections += NavSection(
"Developer",
listOf(
NavDest(Routes.BOARD, "Bounty Board", Icons.Outlined.Storefront),
NavDest(Routes.MY_TASKS, "My Tasks", Icons.Outlined.Assignment),
NavDest(Routes.DEV_METRICS, "Metrics", Icons.Outlined.Insights),
),
)
}
if (roles.consultant) {
sections += NavSection(
"Consultant",
listOf(
NavDest(Routes.CONSULTANT_BOARD, "Atomization", Icons.Outlined.AutoAwesomeMosaic),
NavDest(Routes.REVIEWS, "Reviews", Icons.Outlined.RateReview),
NavDest(Routes.POOL, "Developer Pool", Icons.Outlined.Groups),
NavDest(Routes.CONSULTANT_METRICS, "Metrics", Icons.Outlined.Insights),
),
)
}
if (roles.admin) {
sections += NavSection(
"Admin",
listOf(
NavDest(Routes.ADMIN_USERS, "Users", Icons.Outlined.AdminPanelSettings),
NavDest(Routes.ADMIN_CUSTOMERS, "Projects", Icons.Outlined.Business),
NavDest(Routes.ADMIN_STATUS, "Service Status", Icons.Outlined.Insights),
NavDest(Routes.ADMIN_AUDIT, "Audit Log", Icons.AutoMirrored.Outlined.ListAlt),
NavDest(Routes.ADMIN_SETTINGS, "Settings", Icons.Outlined.Settings),
),
)
}
sections += NavSection(
"Workspace",
listOf(
NavDest(Routes.MESSAGES, "Messages", Icons.Outlined.Chat),
NavDest(Routes.LEADERBOARD, "Leaderboard", Icons.Outlined.EmojiEvents),
NavDest(Routes.NOTIFICATIONS, "Notifications", Icons.Outlined.Notifications),
NavDest(Routes.PROFILE, "Profile", Icons.Outlined.AccountCircle),
),
)
return sections
}
/** The landing screen for a user, by highest-privilege role. */
fun homeRouteFor(user: User?): String = when {
user == null -> Routes.BOARD
user.roles.developer -> Routes.BOARD
user.roles.consultant -> Routes.CONSULTANT_BOARD
user.roles.admin -> Routes.ADMIN_USERS
else -> Routes.MESSAGES
}
@@ -0,0 +1,58 @@
package com.anypreta.bountyboard.ui.nav
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.anypreta.bountyboard.data.AppContainer
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class ShellViewModel(private val container: AppContainer) : ViewModel() {
private val _unread = MutableStateFlow(0L)
val unread: StateFlow<Long> = _unread.asStateFlow()
init {
viewModelScope.launch {
while (isActive) {
refreshUnread()
delay(30_000)
}
}
}
fun refreshUnread() {
viewModelScope.launch {
try {
val r = container.repo.call { notifications(unread = true, limit = 1) }
_unread.value = r.unread
} catch (_: Exception) {
}
}
}
fun toggleTheme() {
viewModelScope.launch {
val current = container.settings.theme.first()
container.settings.setTheme(if (current == "dark") "light" else "dark")
}
}
fun logout(onDone: () -> Unit) {
viewModelScope.launch {
try {
container.repo.action { logout() }
} catch (_: Exception) {
}
container.cancelPush()
container.clearPushState()
container.cookieJar.clear()
container.session.clear()
onDone()
}
}
}
@@ -0,0 +1,379 @@
package com.anypreta.bountyboard.ui.profile
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import kotlinx.coroutines.flow.first
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.Contact
import com.anypreta.bountyboard.data.model.User
import com.anypreta.bountyboard.data.net.ProfilePatchBody
import com.anypreta.bountyboard.data.net.ProfileSettingsPatch
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBAvatar
import com.anypreta.bountyboard.ui.components.BBBadge
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.BBTextField
import com.anypreta.bountyboard.ui.components.ConfirmDialog
import com.anypreta.bountyboard.ui.components.FilterPill
import com.anypreta.bountyboard.ui.components.Hairline
import com.anypreta.bountyboard.ui.components.MessageBox
import com.anypreta.bountyboard.ui.components.MessageKind
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.SectionLabel
import com.anypreta.bountyboard.ui.theme.BB
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
private val THEMES = listOf(
"light" to "Light",
"dark" to "Dark",
"neon" to "Neon",
"terminal" to "Terminal",
"blueprint" to "Blueprint",
"sunset" to "Sunset",
)
class ProfileViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var user by mutableStateOf<User?>(null)
private set
var actionLoading by mutableStateOf(false)
private set
var actionError by mutableStateOf<String?>(null)
private set
var actionInfo by mutableStateOf<String?>(null)
private set
var pushEnabled by mutableStateOf(true)
private set
init {
load()
viewModelScope.launch { pushEnabled = container.settings.pushEnabled.first() }
}
fun setPush(enabled: Boolean) {
pushEnabled = enabled
viewModelScope.launch {
container.settings.setPushEnabled(enabled)
if (enabled) container.schedulePush() else container.cancelPush()
}
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
user = container.repo.call { profile() }.user
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
fun uploadAvatar(bytes: ByteArray, name: String, mime: String) {
actionLoading = true; actionError = null; actionInfo = null
viewModelScope.launch {
try {
val body = bytes.toRequestBody(mime.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", name, body)
container.repo.call { uploadAvatar(part) }
val updated = container.repo.call { profile() }.user
user = updated
container.session.setSession(updated, false)
actionInfo = "Photo updated."
} catch (e: Exception) {
actionError = e.message
} finally {
actionLoading = false
}
}
}
fun save(name: String, bio: String, phone: String, location: String, theme: String, optOut: Boolean) {
val current = user ?: return
actionLoading = true; actionError = null; actionInfo = null
viewModelScope.launch {
try {
val contact = Contact(
phone = phone.ifBlank { null },
location = location.ifBlank { null },
links = current.contact?.links,
)
val body = ProfilePatchBody(
name = name.trim(),
bio = bio,
contact = contact,
settings = ProfileSettingsPatch(theme = theme, leaderboardOptOut = optOut),
version = current.version,
)
val updated = container.repo.call { patchProfile(body) }.user
user = updated
container.session.setSession(updated, false)
actionInfo = "Profile saved."
} catch (e: Exception) {
actionError = e.message
} finally {
actionLoading = false
}
}
}
fun applyTheme(themeId: String) {
viewModelScope.launch {
container.settings.setTheme(themeId)
}
}
fun logoutAll(onDone: () -> Unit) {
actionLoading = true; actionError = null
viewModelScope.launch {
try {
container.repo.call { logoutAll() }
container.session.clear()
container.cookieJar.clear()
onDone()
} catch (e: Exception) {
actionError = e.message
} finally {
actionLoading = false
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ProfileScreen() {
val container = LocalAppContainer.current
val vm: ProfileViewModel = viewModel(factory = containerVMFactory(container) { ProfileViewModel(it) })
val ctx = LocalContext.current
val p = BB.colors
var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") }
var phone by remember { mutableStateOf("") }
var location by remember { mutableStateOf("") }
var theme by remember { mutableStateOf("light") }
var optOut by remember { mutableStateOf(false) }
var initialized by remember { mutableStateOf(false) }
var confirmLogout by remember { mutableStateOf(false) }
LaunchedEffect(vm.user) {
val u = vm.user
if (u != null && !initialized) {
name = u.name
bio = u.bio ?: ""
phone = u.contact?.phone ?: ""
location = u.contact?.location ?: ""
theme = u.settings?.theme ?: "light"
optOut = u.settings?.leaderboardOptOut ?: false
initialized = true
}
}
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) {
val bytes = ctx.contentResolver.openInputStream(uri)?.use { it.readBytes() }
if (bytes != null) {
val fileName = queryName(ctx, uri)
val mime = ctx.contentResolver.getType(uri) ?: "application/octet-stream"
vm.uploadAvatar(bytes, fileName, mime)
}
}
}
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
val user = vm.user ?: return@ScreenState
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
// Avatar
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
BBAvatar(user.name, user.avatarFileId, size = 96.dp)
BBButton(
"Change photo",
onClick = { launcher.launch("image/*") },
style = BBButtonStyle.Default,
small = true,
loading = vm.actionLoading,
)
}
vm.actionError?.let { MessageBox(it, MessageKind.Error) }
vm.actionInfo?.let { MessageBox(it, MessageKind.Ok) }
// Identity
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionLabel("Account")
Text(user.email, style = MaterialTheme.typography.bodyLarge, color = p.text)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (user.roles.admin) BBBadge("Admin", color = p.accent)
if (user.roles.consultant) BBBadge("Consultant", color = p.ok)
if (user.roles.developer) BBBadge("Developer", color = p.muted)
}
}
}
// Editable fields
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SectionLabel("Profile")
BBTextField(name, { name = it }, "Name", modifier = Modifier.fillMaxWidth())
BBTextField(bio, { bio = it }, "Bio", singleLine = false, minLines = 3, modifier = Modifier.fillMaxWidth())
BBTextField(phone, { phone = it }, "Phone", modifier = Modifier.fillMaxWidth())
BBTextField(location, { location = it }, "Location", modifier = Modifier.fillMaxWidth())
}
}
// Theme picker
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SectionLabel("Theme")
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
THEMES.forEach { (id, label) ->
FilterPill(
text = if (theme == id) "$label" else label,
showCaret = false,
onClick = {
theme = id
vm.applyTheme(id)
},
)
}
}
Hairline()
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Hide me from the leaderboard", style = MaterialTheme.typography.bodyMedium, color = p.text)
Switch(
checked = optOut,
onCheckedChange = { optOut = it },
colors = SwitchDefaults.colors(
checkedThumbColor = p.onAccent,
checkedTrackColor = p.accent,
uncheckedTrackColor = p.surface2,
uncheckedBorderColor = p.border,
),
)
}
Hairline()
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text("Push notifications", style = MaterialTheme.typography.bodyMedium, color = p.text)
Text("Background alerts & messages (~15 min)", style = MaterialTheme.typography.labelSmall, color = p.muted)
}
Switch(
checked = vm.pushEnabled,
onCheckedChange = { vm.setPush(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = p.onAccent,
checkedTrackColor = p.accent,
uncheckedTrackColor = p.surface2,
uncheckedBorderColor = p.border,
),
)
}
}
}
BBButton(
"Save",
onClick = { vm.save(name, bio, phone, location, theme, optOut) },
style = BBButtonStyle.Primary,
loading = vm.actionLoading,
modifier = Modifier.fillMaxWidth(),
)
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
BBButton(
"Sign out everywhere",
onClick = { confirmLogout = true },
style = BBButtonStyle.Danger,
)
}
Spacer(Modifier.height(32.dp))
}
}
if (confirmLogout) {
ConfirmDialog(
title = "Sign out everywhere",
message = "This revokes every active session on all devices. You'll need to sign in again.",
confirmText = "Sign out",
danger = true,
onConfirm = { confirmLogout = false; vm.logoutAll {} },
onDismiss = { confirmLogout = false },
)
}
}
private fun queryName(ctx: android.content.Context, uri: Uri): String {
var name = "upload"
try {
ctx.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && cursor.moveToFirst()) {
cursor.getString(idx)?.let { if (it.isNotBlank()) name = it }
}
}
} catch (_: Exception) {
}
return name
}
@@ -0,0 +1,197 @@
package com.anypreta.bountyboard.ui.profile
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Link
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.UserCard
import com.anypreta.bountyboard.data.net.CreateConversationBody
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBAvatar
import com.anypreta.bountyboard.ui.components.BBBadge
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.Hairline
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.SectionLabel
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.theme.Fraunces
import kotlinx.coroutines.launch
class PublicProfileViewModel(
private val container: AppContainer,
private val userId: String,
) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var card by mutableStateOf<UserCard?>(null)
private set
init {
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
card = container.repo.call { userCard(userId) }.card
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
fun startDm(onDone: (String) -> Unit) {
viewModelScope.launch {
try {
val r = container.repo.call {
createConversation(CreateConversationBody(kind = "dm", participantIds = listOf(userId)))
}
onDone(r.conversation.id)
} catch (e: Exception) {
error = e.message
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PublicProfileScreen(userId: String, onOpenConversation: (String) -> Unit) {
val container = LocalAppContainer.current
val vm: PublicProfileViewModel = viewModel(
key = "pubprofile_$userId",
factory = containerVMFactory(container) { PublicProfileViewModel(it, userId) },
)
val p = BB.colors
val uriHandler = LocalUriHandler.current
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
val card = vm.card ?: return@ScreenState
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
BBAvatar(card.name, card.avatarFileId, size = 96.dp)
Text(
card.name,
fontFamily = Fraunces,
style = MaterialTheme.typography.headlineMedium,
color = p.text,
textAlign = TextAlign.Center,
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (card.roles.admin) BBBadge("Admin", color = p.accent)
if (card.roles.consultant) BBBadge("Consultant", color = p.ok)
if (card.roles.developer) BBBadge("Developer", color = p.muted)
}
BBButton(
"Message",
onClick = { vm.startDm { onOpenConversation(it) } },
style = BBButtonStyle.Primary,
)
}
if (!card.bio.isNullOrBlank()) {
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionLabel("About")
Text(card.bio!!, style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
}
val contact = card.contact
val hasContact = contact != null && (!contact.phone.isNullOrBlank() || !contact.location.isNullOrBlank() || !contact.links.isNullOrEmpty())
if (hasContact) {
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionLabel("Contact")
contact?.phone?.takeIf { it.isNotBlank() }?.let {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Outlined.Phone, null, tint = p.muted, modifier = Modifier.size(16.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
contact?.location?.takeIf { it.isNotBlank() }?.let {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Outlined.LocationOn, null, tint = p.muted, modifier = Modifier.size(16.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
contact?.links?.filter { it.isNotBlank() }?.forEach { link ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.clickable { uriHandler.openUri(link) },
) {
Icon(Icons.Outlined.Link, null, tint = p.accent, modifier = Modifier.size(16.dp))
Text(link, style = MaterialTheme.typography.bodyMedium, color = p.accent, maxLines = 1)
}
}
}
}
}
val extra = card.extra?.filterValues { it != null }
if (!extra.isNullOrEmpty()) {
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionLabel("Details")
extra.entries.forEachIndexed { i, (k, v) ->
if (i > 0) Hairline()
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(k, style = MaterialTheme.typography.labelSmall, color = p.muted)
Text(v.toString(), style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
}
}
}
Spacer(Modifier.height(32.dp))
}
}
}
@@ -0,0 +1,181 @@
package com.anypreta.bountyboard.ui.shared
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.Notification
import com.anypreta.bountyboard.data.net.NotificationsReadBody
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.EmptyState
import com.anypreta.bountyboard.ui.components.FilterPill
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.util.relativeTime
import kotlinx.coroutines.launch
class NotificationsViewModel(private val container: AppContainer) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var notifications by mutableStateOf<List<Notification>>(emptyList())
private set
var unread by mutableStateOf(0L)
private set
var unreadOnly by mutableStateOf(false)
private set
init {
load()
}
fun toggleUnreadOnly() {
unreadOnly = !unreadOnly
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
val r = container.repo.call {
notifications(unread = if (unreadOnly) true else null, cursor = null, limit = 50)
}
notifications = r.notifications
unread = r.unread
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
fun markRead(ids: List<String>, onChanged: () -> Unit) {
viewModelScope.launch {
try {
container.repo.call { markNotificationsRead(NotificationsReadBody(ids)) }
load()
onChanged()
} catch (e: Exception) {
error = e.message
}
}
}
}
@Composable
fun NotificationsScreen(onOpenLink: (String) -> Unit, onChanged: () -> Unit) {
val container = LocalAppContainer.current
val vm: NotificationsViewModel = viewModel(factory = containerVMFactory(container) { NotificationsViewModel(it) })
val p = BB.colors
Column(Modifier.fillMaxWidth()) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterPill(
text = if (vm.unreadOnly) "Unread only" else "All",
showCaret = false,
onClick = { vm.toggleUnreadOnly() },
)
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
BBButton(
"Mark all read",
onClick = { vm.markRead(emptyList()) { onChanged() } },
style = BBButtonStyle.Default,
small = true,
enabled = vm.unread > 0,
)
}
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
if (vm.notifications.isEmpty()) {
EmptyState(
title = "You're all caught up",
subtitle = "New activity on your tasks and conversations shows up here.",
icon = Icons.Outlined.NotificationsNone,
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(vm.notifications, key = { it.id }) { n ->
NotificationRow(n) {
if (n.isUnread) {
vm.markRead(listOf(n.id)) { onChanged() }
}
n.link?.let { onOpenLink(it) }
}
}
}
}
}
}
}
@Composable
private fun NotificationRow(n: Notification, onClick: () -> Unit) {
val p = BB.colors
BBCard(modifier = Modifier.fillMaxWidth(), onClick = onClick, padding = PaddingValues(0.dp)) {
Row(Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min)) {
if (n.isUnread) {
Box(Modifier.width(4.dp).fillMaxHeight().background(p.accent))
}
Column(
Modifier
.fillMaxWidth()
.then(if (n.isUnread) Modifier.background(p.surface2) else Modifier)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
n.title,
style = MaterialTheme.typography.titleSmall,
color = p.text,
fontWeight = FontWeight.Bold,
)
if (n.body.isNotBlank()) {
Text(n.body, style = MaterialTheme.typography.bodyMedium, color = p.muted)
}
Text(relativeTime(n.createdAt), style = MaterialTheme.typography.labelSmall, color = p.muted)
}
}
}
}
@@ -0,0 +1,358 @@
package com.anypreta.bountyboard.ui.task
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.Link
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.anypreta.bountyboard.data.model.Task
import com.anypreta.bountyboard.di.LocalAppContainer
import com.anypreta.bountyboard.di.containerVMFactory
import com.anypreta.bountyboard.ui.components.BBAvatar
import com.anypreta.bountyboard.ui.components.BBBadge
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBCard
import com.anypreta.bountyboard.ui.components.BountyChip
import com.anypreta.bountyboard.ui.components.ConfirmDialog
import com.anypreta.bountyboard.ui.components.Hairline
import com.anypreta.bountyboard.ui.components.MessageBox
import com.anypreta.bountyboard.ui.components.MessageKind
import com.anypreta.bountyboard.ui.components.NoteDialog
import com.anypreta.bountyboard.ui.components.ScreenState
import com.anypreta.bountyboard.ui.components.SectionLabel
import com.anypreta.bountyboard.ui.components.StatusBadge
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.util.formatBounty
import com.anypreta.bountyboard.ui.util.formatMinutes
import com.anypreta.bountyboard.ui.util.htmlToPlain
import com.anypreta.bountyboard.ui.util.relativeTime
import com.anypreta.bountyboard.ui.util.statusLabel
private enum class TaskDialog { Claim, Withdraw, Abandon, Comment, Time, Subdivide, Extend, AssignAi, Review, Edit, Unassign, Archive }
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TaskDetailScreen(taskId: String, onBack: () -> Unit, onOpenProfile: (String) -> Unit) {
val container = LocalAppContainer.current
val vm: TaskDetailViewModel = viewModel(
key = "task_$taskId",
factory = containerVMFactory(container) { TaskDetailViewModel(it, taskId) },
)
val me by container.session.user.collectAsStateWithLifecycle()
val p = BB.colors
val uriHandler = LocalUriHandler.current
var dialog by remember { mutableStateOf<TaskDialog?>(null) }
ScreenState(loading = vm.loading, error = vm.error, onRetry = { vm.load() }) {
val task = vm.task ?: return@ScreenState
val myId = me?.id
val roles = me?.roles
val isCons = roles?.consultant == true || roles?.admin == true
val isDev = roles?.developer == true
val isAssignee = task.assignee?.kind == "human" && task.assignee?.userId == myId
val claimedByMe = task.claimRequests?.any { it.developerId == myId } == true
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
// Header
BBCard(modifier = Modifier.fillMaxWidth(), topStrip = true) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
BountyChip(task.bounty, large = true)
StatusBadge(task.status)
}
Text(task.title, style = MaterialTheme.typography.headlineMedium, color = p.text)
task.external?.let { ext ->
if (!ext.key.isNullOrBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.clickable(enabled = !ext.url.isNullOrBlank()) { ext.url?.let { uriHandler.openUri(it) } },
) {
Icon(Icons.Outlined.Link, null, tint = p.muted, modifier = Modifier.size(14.dp))
Text("${ext.system ?: ""} ${ext.key}".trim(), style = MaterialTheme.typography.labelSmall, color = p.muted)
if (ext.orphaned) BBBadge("Orphaned", color = p.err)
}
}
}
Hairline()
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
MetaStat("Budget", "${formatBounty(task.budget)}")
MetaStat("Coefficient", formatBounty(task.effortCoefficient))
MetaStat("Origin", task.origin.replaceFirstChar { it.uppercase() })
}
task.assignee?.let { a ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SectionLabel("Assignee")
if (a.kind == "ai") {
BBBadge("AI performer", color = p.accent)
} else {
Text(vm.nameFor(a.userId), style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
}
}
}
// Action messages
vm.actionError?.let { MessageBox(it, MessageKind.Error) }
vm.actionInfo?.let { MessageBox(it, MessageKind.Ok) }
// Actions
val actions = buildList {
if (isDev) {
if (task.status == "published" && !claimedByMe) add("Request assignment" to { dialog = TaskDialog.Claim })
if ((task.status == "published" || task.status == "claim_requested") && claimedByMe) add("Withdraw claim" to { dialog = TaskDialog.Withdraw })
if (isAssignee && task.status == "assigned") add("Start work" to { vm.start() })
if (isAssignee && task.status == "changes_requested") add("Resume work" to { vm.start() })
if (isAssignee && task.status == "in_progress") {
add("Submit for review" to { vm.submitReview() })
add("Abandon" to { dialog = TaskDialog.Abandon })
}
if (isAssignee && task.status in listOf("assigned", "in_progress", "changes_requested")) {
add("Log time" to { dialog = TaskDialog.Time })
}
}
if (isCons) {
if (task.status in listOf("imported", "atomized")) {
add("Subdivide" to { dialog = TaskDialog.Subdivide })
add("Extend" to { dialog = TaskDialog.Extend })
}
if (task.status == "atomized") add("Publish" to { vm.publish() })
if (task.status in listOf("published", "claim_requested")) add("Assign to AI" to { dialog = TaskDialog.AssignAi })
if (task.status in listOf("assigned", "in_progress", "changes_requested")) add("Unassign" to { dialog = TaskDialog.Unassign })
if (task.status == "in_review") add("Review" to { dialog = TaskDialog.Review })
if (task.status !in listOf("approved", "archived")) add("Edit" to { dialog = TaskDialog.Edit })
if (task.status != "archived") add("Archive" to { dialog = TaskDialog.Archive })
}
add("Comment" to { dialog = TaskDialog.Comment })
}
if (actions.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
actions.forEachIndexed { i, (label, onClick) ->
BBButton(
label,
onClick = onClick,
style = if (i == 0) BBButtonStyle.Primary else BBButtonStyle.Default,
small = true,
loading = vm.actionLoading && i == 0,
)
}
}
}
// Description
if (task.description.isNotBlank()) {
SectionCard("Description") {
Text(htmlToPlain(task.description), style = MaterialTheme.typography.bodyLarge, color = p.text)
}
}
// Acceptance criteria
task.acceptanceCriteria?.takeIf { it.isNotEmpty() }?.let { acs ->
SectionCard("Acceptance criteria") {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
acs.forEach { c ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("", color = p.accent, style = MaterialTheme.typography.bodyMedium)
Text(c, style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
}
}
}
// Attachments & links
val hasAttach = !task.attachments.isNullOrEmpty()
val hasLinks = !task.links.isNullOrEmpty()
if (hasAttach || hasLinks) {
SectionCard("Attachments & links") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
task.attachments?.forEach { a ->
LinkRow(Icons.Outlined.AttachFile, a.name ?: "Attachment") {
val url = a.url ?: a.fileId?.let { container.fileUrl(it) }
url?.let { uriHandler.openUri(it) }
}
}
task.links?.forEach { l ->
LinkRow(Icons.Outlined.Link, l) { uriHandler.openUri(l) }
}
}
}
}
// Claim requests
task.claimRequests?.takeIf { it.isNotEmpty() }?.let { claims ->
SectionCard("Claim requests (${claims.size})") {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
claims.forEach { c ->
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.clickable { onOpenProfile(c.developerId) }) {
BBAvatar(vm.nameFor(c.developerId), null, size = 28.dp)
Column {
Text(vm.nameFor(c.developerId), style = MaterialTheme.typography.bodyMedium, color = p.text, fontWeight = FontWeight.Bold)
Text(relativeTime(c.at), style = MaterialTheme.typography.labelSmall, color = p.muted)
}
}
if (c.developerId == myId) BBBadge("You", color = p.ok)
}
if (!c.note.isNullOrBlank()) {
Text(c.note!!, style = MaterialTheme.typography.bodyMedium, color = p.muted)
}
if (isCons && task.status in listOf("published", "claim_requested")) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
BBButton("Approve", onClick = { vm.approveClaim(c.developerId) }, style = BBButtonStyle.Primary, small = true)
BBButton("Decline", onClick = { vm.declineClaim(c.developerId) }, style = BBButtonStyle.Ghost, small = true)
}
}
Hairline()
}
}
}
}
}
// Comments
SectionCard("Comments (${task.comments?.size ?: 0})") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (task.comments.isNullOrEmpty()) {
Text("No comments yet.", style = MaterialTheme.typography.bodyMedium, color = p.muted)
} else {
task.comments!!.forEach { c ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
BBAvatar(vm.nameFor(c.authorId), null, size = 28.dp)
Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(vm.nameFor(c.authorId), style = MaterialTheme.typography.titleSmall, color = p.text)
Text(relativeTime(c.at), style = MaterialTheme.typography.labelSmall, color = p.muted)
}
Text(htmlToPlain(c.body), style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
}
}
BBButton("Add comment", onClick = { dialog = TaskDialog.Comment }, style = BBButtonStyle.Default, small = true)
}
}
// Time log
task.timeLog?.takeIf { it.isNotEmpty() }?.let { logs ->
SectionCard("Time log") {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
val total = logs.sumOf { it.minutes }
Text("Total: ${formatMinutes(total)}", style = MaterialTheme.typography.titleSmall, color = p.accent)
logs.forEach { e ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(e.note?.ifBlank { "" } ?: "", style = MaterialTheme.typography.bodyMedium, color = p.text)
Text(formatMinutes(e.minutes), style = MaterialTheme.typography.bodyMedium, color = p.muted)
}
}
}
}
}
// Timeline
task.timeline?.takeIf { it.isNotEmpty() }?.let { tl ->
SectionCard("Timeline") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
tl.reversed().take(30).forEach { e ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("", color = p.accent)
Column {
Text(statusLabel(e.event ?: ""), style = MaterialTheme.typography.bodyMedium, color = p.text)
Text(relativeTime(e.at), style = MaterialTheme.typography.labelSmall, color = p.muted)
}
}
}
}
}
}
Spacer(Modifier.height(40.dp))
}
// Dialogs
when (dialog) {
TaskDialog.Claim -> NoteDialog("Request assignment", "Pitch note (optional)", "Request", onConfirm = { vm.claim(it.ifBlank { null }); dialog = null }, onDismiss = { dialog = null }, message = "Optionally add a pitch.")
TaskDialog.Withdraw -> ConfirmDialog("Withdraw claim", "Remove your assignment request from this task?", "Withdraw", onConfirm = { vm.withdrawClaim(); dialog = null }, onDismiss = { dialog = null })
TaskDialog.Abandon -> ConfirmDialog("Abandon task", "Return this task to the board? Your progress note stays in the timeline.", "Abandon", danger = true, onConfirm = { vm.abandon(); dialog = null }, onDismiss = { dialog = null })
TaskDialog.Comment -> NoteDialog("Add comment", "Comment", "Post", required = true, onConfirm = { vm.comment(it); dialog = null }, onDismiss = { dialog = null }, message = "Mention teammates with @name.")
TaskDialog.Time -> TimeDialog(onConfirm = { m, n -> vm.logTime(m, n); dialog = null }, onDismiss = { dialog = null })
TaskDialog.Subdivide -> SubdivideDialog(onConfirm = { note, min, max -> vm.subdivide(note, min, max); dialog = null }, onDismiss = { dialog = null })
TaskDialog.Extend -> NoteDialog("Extend task", "Extension note", "Extend", required = true, onConfirm = { vm.extend(it); dialog = null }, onDismiss = { dialog = null }, message = "Describe the parallel task to create.")
TaskDialog.AssignAi -> AssignAiDialog(onConfirm = { r, b, i -> vm.assignAi(r, b, i); dialog = null }, onDismiss = { dialog = null })
TaskDialog.Review -> ReviewDialog(task, onConfirm = { a, n, cl -> vm.review(a, n, cl); dialog = null }, onDismiss = { dialog = null })
TaskDialog.Edit -> EditTaskDialog(task, onConfirm = { t, d, ac, c, b -> vm.patch(t, d, ac, c, b) { dialog = null } }, onDismiss = { dialog = null })
TaskDialog.Unassign -> ConfirmDialog("Unassign task", "Return this task to the board?", "Unassign", danger = true, onConfirm = { vm.unassign(); dialog = null }, onDismiss = { dialog = null })
TaskDialog.Archive -> ConfirmDialog("Archive task", "Archive this task? It will leave all active boards.", "Archive", danger = true, onConfirm = { vm.archive(); dialog = null }, onDismiss = { dialog = null })
null -> {}
}
}
}
@Composable
private fun MetaStat(label: String, value: String) {
val p = BB.colors
Column {
SectionLabel(label)
Text(value, style = MaterialTheme.typography.titleMedium, color = p.text)
}
}
@Composable
private fun SectionCard(title: String, content: @Composable () -> Unit) {
BBCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionLabel(title)
content()
}
}
}
@Composable
private fun LinkRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) {
val p = BB.colors
Row(
Modifier.fillMaxWidth().clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(icon, null, tint = p.accent, modifier = Modifier.size(16.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, color = p.accent, modifier = Modifier.weight(1f), maxLines = 1)
Icon(Icons.Outlined.OpenInNew, null, tint = p.muted, modifier = Modifier.size(14.dp))
}
}
@@ -0,0 +1,164 @@
package com.anypreta.bountyboard.ui.task
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.anypreta.bountyboard.data.AppContainer
import com.anypreta.bountyboard.data.model.Task
import com.anypreta.bountyboard.data.net.ApproveClaimBody
import com.anypreta.bountyboard.data.net.AssignAiBody
import com.anypreta.bountyboard.data.net.AssignAiContext
import com.anypreta.bountyboard.data.net.ChecklistItem
import com.anypreta.bountyboard.data.net.ClaimBody
import com.anypreta.bountyboard.data.net.CommentBody
import com.anypreta.bountyboard.data.net.DeclineClaimBody
import com.anypreta.bountyboard.data.net.ExtendBody
import com.anypreta.bountyboard.data.net.IdsBody
import com.anypreta.bountyboard.data.net.ReviewBody
import com.anypreta.bountyboard.data.net.SubdivideBody
import com.anypreta.bountyboard.data.net.SubdivideConstraints
import com.anypreta.bountyboard.data.net.TaskPatchBody
import com.anypreta.bountyboard.data.net.TimeBody
import kotlinx.coroutines.launch
class TaskDetailViewModel(
private val container: AppContainer,
private val taskId: String,
) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var task by mutableStateOf<Task?>(null)
private set
var actionLoading by mutableStateOf(false)
private set
var actionError by mutableStateOf<String?>(null)
private set
var actionInfo by mutableStateOf<String?>(null)
private set
var names by mutableStateOf<Map<String, String>>(emptyMap())
private set
val myId: String? get() = container.session.user.value?.id
init {
load()
}
fun load() {
loading = true; error = null
viewModelScope.launch {
try {
val r = container.repo.call { task(taskId) }
task = r.task
resolveNames(r.task)
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
private fun resolveNames(t: Task) {
val ids = buildSet {
t.claimRequests?.forEach { add(it.developerId) }
t.comments?.forEach { add(it.authorId) }
t.assignee?.userId?.let { add(it) }
t.consultantId.takeIf { it.isNotBlank() }?.let { add(it) }
}.filter { it.isNotBlank() && it !in names }
if (ids.isEmpty()) return
viewModelScope.launch {
val resolved = names.toMutableMap()
ids.forEach { id ->
try {
val c = container.repo.call { userCard(id) }
resolved[id] = c.card.name
} catch (_: Exception) {
}
}
names = resolved
}
}
fun nameFor(id: String?): String = id?.let { names[it] ?: it.takeLast(6) } ?: ""
fun avatarFor(id: String?): String? = null // cards aren't cached for avatars; initials used
private fun act(block: suspend () -> Any?) {
actionLoading = true; actionError = null; actionInfo = null
viewModelScope.launch {
try {
block()
load()
} catch (e: Exception) {
actionError = e.message
} finally {
actionLoading = false
}
}
}
// Developer actions
fun claim(note: String?) = act { container.repo.action { claim(taskId, ClaimBody(note)) } }
fun withdrawClaim() = act { container.repo.action { withdrawClaim(taskId) } }
fun start() = act { container.repo.action { start(taskId) } }
fun submitReview() = act { container.repo.action { submitReview(taskId) } }
fun abandon() = act { container.repo.action { abandon(taskId) } }
fun comment(body: String) = act { container.repo.call { addComment(taskId, CommentBody(body)) } }
fun logTime(minutes: Int, note: String?) = act { container.repo.call { logTime(taskId, TimeBody(minutes, note)) } }
// Consultant actions
fun approveClaim(devId: String) = act { container.repo.action { approveClaim(taskId, ApproveClaimBody(devId)) } }
fun declineClaim(devId: String) = act { container.repo.action { declineClaim(taskId, DeclineClaimBody(devId)) } }
fun unassign() = act { container.repo.action { unassign(taskId) } }
fun publish() = act { container.repo.call { publish(taskId) } }
fun archive() = act { container.repo.action { archiveTask(taskId) } }
fun subdivide(note: String?, min: Int?, max: Int?) = act {
val c = if (min != null || max != null) SubdivideConstraints(min, max) else null
container.repo.call { subdivide(taskId, SubdivideBody(note?.ifBlank { null }, c, confirmReplace = true)) }
actionInfo = "Subdivision queued — results stream in shortly."
}
fun extend(note: String) = act {
container.repo.call { extend(taskId, ExtendBody(note)) }
actionInfo = "Extension queued."
}
fun assignAi(repo: String?, branch: String?, instructions: String?) = act {
val ctx = AssignAiContext(repo?.ifBlank { null }, branch?.ifBlank { null }, instructions?.ifBlank { null })
container.repo.call { assignAi(taskId, AssignAiBody(ctx)) }
actionInfo = "Work performer job created."
}
fun review(approve: Boolean, note: String?, checklist: List<ChecklistItem>) = act {
container.repo.action {
review(taskId, ReviewBody(if (approve) "approve" else "request_changes", note?.ifBlank { null }, checklist.ifEmpty { null }))
}
}
fun patch(title: String, description: String, ac: List<String>, coefficient: Double, budget: Double, onDone: () -> Unit) {
val v = task?.version ?: return
actionLoading = true; actionError = null
viewModelScope.launch {
try {
container.repo.call {
patchTask(taskId, TaskPatchBody(
title = title, description = description,
acceptanceCriteria = ac, effortCoefficient = coefficient, budget = budget, version = v,
))
}
load()
onDone()
} catch (e: Exception) {
actionError = e.message
} finally {
actionLoading = false
}
}
}
fun clearActionMsg() { actionError = null; actionInfo = null }
}
@@ -0,0 +1,206 @@
package com.anypreta.bountyboard.ui.task
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
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.KeyboardType
import androidx.compose.ui.unit.dp
import com.anypreta.bountyboard.data.model.Task
import com.anypreta.bountyboard.data.net.ChecklistItem
import com.anypreta.bountyboard.ui.components.BBButton
import com.anypreta.bountyboard.ui.components.BBButtonStyle
import com.anypreta.bountyboard.ui.components.BBTextField
import com.anypreta.bountyboard.ui.components.FilterPill
import com.anypreta.bountyboard.ui.theme.BB
import com.anypreta.bountyboard.ui.util.formatBounty
@Composable
fun TimeDialog(onConfirm: (Int, String?) -> Unit, onDismiss: () -> Unit) {
val p = BB.colors
var minutes by remember { mutableStateOf("") }
var note by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = p.surface,
title = { Text("Log time", style = MaterialTheme.typography.headlineSmall, color = p.text) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
BBTextField(minutes, { minutes = it.filter { c -> c.isDigit() } }, "Minutes", placeholder = "e.g. 90", keyboardType = KeyboardType.Number)
BBTextField(note, { note = it }, "Note (optional)", singleLine = false, minLines = 2)
}
},
confirmButton = {
BBButton("Log", onClick = { minutes.toIntOrNull()?.let { onConfirm(it, note.ifBlank { null }) } }, enabled = (minutes.toIntOrNull() ?: 0) in 1..1440, style = BBButtonStyle.Primary, small = true)
},
dismissButton = { BBButton("Cancel", onClick = onDismiss, style = BBButtonStyle.Ghost, small = true) },
)
}
@Composable
fun SubdivideDialog(onConfirm: (String?, Int?, Int?) -> Unit, onDismiss: () -> Unit) {
val p = BB.colors
var note by remember { mutableStateOf("") }
var min by remember { mutableStateOf("") }
var max by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = p.surface,
title = { Text("Subdivide task", style = MaterialTheme.typography.headlineSmall, color = p.text) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("AI splits this task into smaller subtasks. Re-running replaces previous unpublished children.", style = MaterialTheme.typography.bodyMedium, color = p.muted)
BBTextField(note, { note = it }, "Guidance note (optional)", placeholder = "split backend/frontend…", singleLine = false, minLines = 2)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
BBTextField(min, { min = it.filter { c -> c.isDigit() } }, "Min tasks", keyboardType = KeyboardType.Number, modifier = Modifier.fillMaxWidth(0.5f))
BBTextField(max, { max = it.filter { c -> c.isDigit() } }, "Max tasks", keyboardType = KeyboardType.Number, modifier = Modifier.fillMaxWidth())
}
}
},
confirmButton = { BBButton("Subdivide", onClick = { onConfirm(note.ifBlank { null }, min.toIntOrNull(), max.toIntOrNull()) }, style = BBButtonStyle.Primary, small = true) },
dismissButton = { BBButton("Cancel", onClick = onDismiss, style = BBButtonStyle.Ghost, small = true) },
)
}
@Composable
fun AssignAiDialog(onConfirm: (String?, String?, String?) -> Unit, onDismiss: () -> Unit) {
val p = BB.colors
var repo by remember { mutableStateOf("") }
var branch by remember { mutableStateOf("") }
var instructions by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = p.surface,
title = { Text("Assign to AI performer", style = MaterialTheme.typography.headlineSmall, color = p.text) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
BBTextField(repo, { repo = it }, "Repository URL (optional)", placeholder = "https://github.com/…", keyboardType = KeyboardType.Uri)
BBTextField(branch, { branch = it }, "Branch (optional)")
BBTextField(instructions, { instructions = it }, "Extra instructions (optional)", singleLine = false, minLines = 3)
}
},
confirmButton = { BBButton("Create job", onClick = { onConfirm(repo, branch, instructions) }, style = BBButtonStyle.Primary, small = true) },
dismissButton = { BBButton("Cancel", onClick = onDismiss, style = BBButtonStyle.Ghost, small = true) },
)
}
@Composable
fun ReviewDialog(
task: Task,
onConfirm: (approve: Boolean, note: String?, checklist: List<ChecklistItem>) -> Unit,
onDismiss: () -> Unit,
) {
val p = BB.colors
val criteria = task.acceptanceCriteria ?: emptyList()
val checks = remember { mutableStateMapOf<Int, Boolean>() }
var note by remember { mutableStateOf("") }
var approve by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = p.surface,
title = { Text("Review submission", style = MaterialTheme.typography.headlineSmall, color = p.text) },
text = {
Column(
Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (criteria.isNotEmpty()) {
Text("Acceptance criteria", style = MaterialTheme.typography.titleSmall, color = p.text)
criteria.forEachIndexed { i, c ->
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = checks[i] ?: false,
onCheckedChange = { checks[i] = it },
colors = CheckboxDefaults.colors(checkedColor = p.ok, uncheckedColor = p.border),
)
Text(c, style = MaterialTheme.typography.bodyMedium, color = p.text)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
BBButton("Approve", onClick = { approve = true }, style = if (approve) BBButtonStyle.Primary else BBButtonStyle.Ghost, small = true)
BBButton("Request changes", onClick = { approve = false }, style = if (!approve) BBButtonStyle.Primary else BBButtonStyle.Ghost, small = true)
}
Text(if (approve) "Approving freezes the bounty into the ledger." else "Requesting changes returns the task to the developer.", style = MaterialTheme.typography.labelSmall, color = if (approve) p.ok else p.warn)
BBTextField(note, { note = it }, "Review note (optional)", singleLine = false, minLines = 2)
}
},
confirmButton = {
BBButton(
if (approve) "Approve" else "Request changes",
onClick = {
val checklist = criteria.mapIndexed { i, c -> ChecklistItem(c, checks[i] ?: false) }
onConfirm(approve, note.ifBlank { null }, checklist)
},
style = if (approve) BBButtonStyle.Primary else BBButtonStyle.Default,
small = true,
)
},
dismissButton = { BBButton("Cancel", onClick = onDismiss, style = BBButtonStyle.Ghost, small = true) },
)
}
@Composable
fun EditTaskDialog(task: Task, onConfirm: (String, String, List<String>, Double, Double) -> Unit, onDismiss: () -> Unit) {
val p = BB.colors
var title by remember { mutableStateOf(task.title) }
var description by remember { mutableStateOf(task.description) }
var ac by remember { mutableStateOf((task.acceptanceCriteria ?: emptyList()).joinToString("\n")) }
var coefficient by remember { mutableStateOf(task.effortCoefficient.toString()) }
var budget by remember { mutableStateOf(formatBounty(task.budget)) }
val coef = coefficient.toDoubleOrNull() ?: task.effortCoefficient
val bud = budget.toDoubleOrNull() ?: task.budget
val previewBounty = (coef * bud)
AlertDialog(
onDismissRequest = onDismiss,
containerColor = p.surface,
title = { Text("Edit task", style = MaterialTheme.typography.headlineSmall, color = p.text) },
text = {
Column(
Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
BBTextField(title, { title = it }, "Title")
BBTextField(description, { description = it }, "Description", singleLine = false, minLines = 4)
BBTextField(ac, { ac = it }, "Acceptance criteria (one per line)", singleLine = false, minLines = 3)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
BBTextField(coefficient, { coefficient = it }, "Coefficient", keyboardType = KeyboardType.Decimal, modifier = Modifier.fillMaxWidth(0.5f))
BBTextField(budget, { budget = it }, "Budget", keyboardType = KeyboardType.Decimal, modifier = Modifier.fillMaxWidth())
}
Text("Bounty preview: ◈ ${formatBounty(previewBounty)}", style = MaterialTheme.typography.titleSmall, color = p.accent)
}
},
confirmButton = {
BBButton(
"Save",
onClick = {
val acList = ac.split("\n").map { it.trim() }.filter { it.isNotEmpty() }
onConfirm(title.trim(), description.trim(), acList, coef, bud)
},
enabled = title.isNotBlank(),
style = BBButtonStyle.Primary,
small = true,
)
},
dismissButton = { BBButton("Cancel", onClick = onDismiss, style = BBButtonStyle.Ghost, small = true) },
)
}
@@ -0,0 +1,144 @@
package com.anypreta.bountyboard.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
/**
* The BountyBoard palette — "The Ledger". Warm paper grounds, brown/amber ink,
* editorial accents. Mirrors the web app's CSS custom properties
* (web/static/css/app.css) for light + dark, plus the four alternate themes.
*/
@Immutable
data class BBPalette(
val bg: Color,
val surface: Color,
val surface2: Color,
val border: Color,
val text: Color,
val muted: Color,
val accent: Color,
val onAccent: Color,
val ok: Color,
val warn: Color,
val err: Color,
val radius: Int,
val isDark: Boolean,
)
enum class BBTheme(val id: String, val label: String) {
Light("light", "Light"),
Dark("dark", "Dark"),
Neon("neon", "Neon"),
Terminal("terminal", "Terminal"),
Blueprint("blueprint", "Blueprint"),
Sunset("sunset", "Sunset");
companion object {
fun from(id: String?): BBTheme = entries.firstOrNull { it.id == id } ?: Light
}
}
val LightPalette = BBPalette(
bg = Color(0xFFFFFFFF),
surface = Color(0xFFFDFBF6),
surface2 = Color(0xFFF2E9D8),
border = Color(0xFFA88452),
text = Color(0xFF33230E),
muted = Color(0xFF7C6647),
accent = Color(0xFF7A4A14),
onAccent = Color(0xFFFFFFFF),
ok = Color(0xFF2E6B3C),
warn = Color(0xFF955D0E),
err = Color(0xFF9C3A2E),
radius = 2,
isDark = false,
)
val DarkPalette = BBPalette(
bg = Color(0xFF171209),
surface = Color(0xFF221A0E),
surface2 = Color(0xFF2E2414),
border = Color(0xFF7C5F33),
text = Color(0xFFF3E8D2),
muted = Color(0xFFB59C74),
accent = Color(0xFFD9A548),
onAccent = Color(0xFF241606),
ok = Color(0xFF7FB78A),
warn = Color(0xFFD9A44A),
err = Color(0xFFD97B6C),
radius = 2,
isDark = true,
)
val NeonPalette = BBPalette(
bg = Color(0xFF080014),
surface = Color(0xFF160A32),
surface2 = Color(0xFF221248),
border = Color(0xFF7A3BF0),
text = Color(0xFFECE6FF),
muted = Color(0xFFA48FE0),
accent = Color(0xFF00E5FF),
onAccent = Color(0xFF06000F),
ok = Color(0xFF39FF9E),
warn = Color(0xFFFFD23F),
err = Color(0xFFFF3B8B),
radius = 8,
isDark = true,
)
val TerminalPalette = BBPalette(
bg = Color(0xFF000A02),
surface = Color(0xFF04140A),
surface2 = Color(0xFF06200F),
border = Color(0xFF1F8F3F),
text = Color(0xFF41FF7A),
muted = Color(0xFF1FB04F),
accent = Color(0xFF7DFF9F),
onAccent = Color(0xFF021006),
ok = Color(0xFF7DFF9F),
warn = Color(0xFFD7FF3F),
err = Color(0xFFFF5D5D),
radius = 0,
isDark = true,
)
val BlueprintPalette = BBPalette(
bg = Color(0xFF082A4D),
surface = Color(0xFF0C3460),
surface2 = Color(0xFF114576),
border = Color(0xFF6FB8FF),
text = Color(0xFFEAF4FF),
muted = Color(0xFF9CC6F0),
accent = Color(0xFFFFD24A),
onAccent = Color(0xFF082A4D),
ok = Color(0xFF7FFFD4),
warn = Color(0xFFFFD24A),
err = Color(0xFFFF9A8A),
radius = 0,
isDark = true,
)
val SunsetPalette = BBPalette(
bg = Color(0xFF2A1138),
surface = Color(0xFF3A1A4E),
surface2 = Color(0xFF4D2363),
border = Color(0xFFFF8EC7),
text = Color(0xFFFFF0FB),
muted = Color(0xFFE0A9D6),
accent = Color(0xFFFFB347),
onAccent = Color(0xFF2A1138),
ok = Color(0xFF7EF0C4),
warn = Color(0xFFFFD76A),
err = Color(0xFFFF6B8E),
radius = 12,
isDark = true,
)
fun paletteFor(theme: BBTheme): BBPalette = when (theme) {
BBTheme.Light -> LightPalette
BBTheme.Dark -> DarkPalette
BBTheme.Neon -> NeonPalette
BBTheme.Terminal -> TerminalPalette
BBTheme.Blueprint -> BlueprintPalette
BBTheme.Sunset -> SunsetPalette
}
@@ -0,0 +1,41 @@
package com.anypreta.bountyboard.ui.theme
import androidx.compose.foundation.layout.offset
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* The Y2K signature: a hard offset shadow with NO blur, drawn behind the element.
* Replaces Material's soft elevation. Mirrors the web's `--ink-shadow: 3px 3px 0`.
*/
fun Modifier.hardShadow(
palette: BBPalette,
offset: Dp = 3.dp,
cornerRadius: Dp = 2.dp,
): Modifier = this.drawBehind {
val o = offset.toPx()
val r = cornerRadius.toPx()
val shadowColor = if (palette.isDark) {
Color.Black.copy(alpha = 0.55f)
} else {
palette.border.copy(alpha = 0.45f)
}
drawRoundRect(
color = shadowColor,
topLeft = Offset(o, o),
size = Size(size.width, size.height),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(r, r),
)
}
/**
* Pressed-button effect: translate content into the page and drop the shadow.
* Use the [pressed] flag from an InteractionSource.
*/
fun Modifier.pressTranslate(pressed: Boolean, amount: Dp = 2.dp): Modifier =
if (pressed) this.offset(amount, amount) else this
@@ -0,0 +1,99 @@
package com.anypreta.bountyboard.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.dp
val LocalBBPalette: ProvidableCompositionLocal<BBPalette> =
staticCompositionLocalOf { LightPalette }
/** Convenience accessor: `BB.colors.accent`. */
object BB {
val colors: BBPalette
@Composable get() = LocalBBPalette.current
}
@Composable
fun BountyBoardTheme(
theme: BBTheme = BBTheme.Light,
content: @Composable () -> Unit,
) {
val p = paletteFor(theme)
val radius = p.radius.dp
val scheme = if (p.isDark) {
darkColorScheme(
primary = p.accent,
onPrimary = p.onAccent,
secondary = p.accent,
onSecondary = p.onAccent,
background = p.bg,
onBackground = p.text,
surface = p.surface,
onSurface = p.text,
surfaceVariant = p.surface2,
onSurfaceVariant = p.muted,
outline = p.border,
outlineVariant = p.border,
error = p.err,
onError = androidx.compose.ui.graphics.Color.White,
primaryContainer = p.surface2,
onPrimaryContainer = p.text,
surfaceContainer = p.surface2,
surfaceContainerHigh = p.surface2,
surfaceContainerHighest = p.surface2,
surfaceContainerLow = p.surface,
surfaceContainerLowest = p.bg,
scrim = androidx.compose.ui.graphics.Color(0xAA000000),
)
} else {
lightColorScheme(
primary = p.accent,
onPrimary = p.onAccent,
secondary = p.accent,
onSecondary = p.onAccent,
background = p.bg,
onBackground = p.text,
surface = p.surface,
onSurface = p.text,
surfaceVariant = p.surface2,
onSurfaceVariant = p.muted,
outline = p.border,
outlineVariant = p.border,
error = p.err,
onError = androidx.compose.ui.graphics.Color.White,
primaryContainer = p.surface2,
onPrimaryContainer = p.text,
surfaceContainer = p.surface2,
surfaceContainerHigh = p.surface2,
surfaceContainerHighest = p.surface2,
surfaceContainerLow = p.surface,
surfaceContainerLowest = p.bg,
scrim = androidx.compose.ui.graphics.Color(0x88000000),
)
}
val shapes = Shapes(
extraSmall = RoundedCornerShape(radius),
small = RoundedCornerShape(radius),
medium = RoundedCornerShape(radius),
large = RoundedCornerShape(radius),
extraLarge = RoundedCornerShape(radius),
)
CompositionLocalProvider(LocalBBPalette provides p) {
MaterialTheme(
colorScheme = scheme,
typography = BBTypography,
shapes = shapes,
content = content,
)
}
}
@@ -0,0 +1,100 @@
package com.anypreta.bountyboard.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import com.anypreta.bountyboard.R
/** Engraved display serif — headings, brand, stat values, avatar initials. */
val Fraunces = FontFamily(
Font(R.font.fraunces_regular, FontWeight.Normal),
Font(R.font.fraunces_semibold, FontWeight.SemiBold),
Font(R.font.fraunces_bold, FontWeight.Bold),
)
/** Characterful grotesk — body, buttons, labels, nav. */
val Schibsted = FontFamily(
Font(R.font.schibsted_regular, FontWeight.Normal),
Font(R.font.schibsted_medium, FontWeight.Medium),
Font(R.font.schibsted_bold, FontWeight.Bold),
)
/** Tabular mono — badges, numerals, table headers, kickers, code. */
val SplineMono = FontFamily(
Font(R.font.spline_mono_regular, FontWeight.Normal),
Font(R.font.spline_mono_medium, FontWeight.Medium),
Font(R.font.spline_mono_bold, FontWeight.Bold),
)
val BBTypography = Typography(
// Display / masthead
displayLarge = TextStyle(
fontFamily = Fraunces, fontWeight = FontWeight.Bold, fontSize = 40.sp, lineHeight = 44.sp,
),
displayMedium = TextStyle(
fontFamily = Fraunces, fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 36.sp,
),
// Headings (h1/h2/h3)
headlineLarge = TextStyle(
fontFamily = Fraunces, fontWeight = FontWeight.SemiBold, fontSize = 30.sp, lineHeight = 35.sp,
),
headlineMedium = TextStyle(
fontFamily = Fraunces, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 27.sp,
),
headlineSmall = TextStyle(
fontFamily = Fraunces, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 23.sp,
),
titleLarge = TextStyle(
fontFamily = Fraunces, fontWeight = FontWeight.SemiBold, fontSize = 20.sp, lineHeight = 25.sp,
),
titleMedium = TextStyle(
fontFamily = Schibsted, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 22.sp,
),
titleSmall = TextStyle(
fontFamily = Schibsted, fontWeight = FontWeight.Bold, fontSize = 14.sp, lineHeight = 20.sp,
),
// Body
bodyLarge = TextStyle(
fontFamily = Schibsted, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp,
letterSpacing = 0.005.em,
),
bodyMedium = TextStyle(
fontFamily = Schibsted, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 21.sp,
letterSpacing = 0.005.em,
),
bodySmall = TextStyle(
fontFamily = Schibsted, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 18.sp,
),
// Labels — buttons use uppercase bold grotesk
labelLarge = TextStyle(
fontFamily = Schibsted, fontWeight = FontWeight.Bold, fontSize = 13.sp, lineHeight = 16.sp,
letterSpacing = 0.06.em,
),
labelMedium = TextStyle(
fontFamily = SplineMono, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp,
letterSpacing = 0.09.em,
),
labelSmall = TextStyle(
fontFamily = SplineMono, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 13.sp,
letterSpacing = 0.12.em,
),
)
/** Mono kicker style — uppercase, wide letter-spacing (the masthead kicker). */
val KickerStyle = TextStyle(
fontFamily = SplineMono,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
letterSpacing = 0.28.em,
)
/** Big ledger stat number. */
val StatValueStyle = TextStyle(
fontFamily = Fraunces,
fontWeight = FontWeight.Bold,
fontSize = 30.sp,
)
@@ -0,0 +1,93 @@
package com.anypreta.bountyboard.ui.util
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
private val isoParsers: List<SimpleDateFormat> by lazy {
listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'",
).map { SimpleDateFormat(it, Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") } }
}
fun parseInstant(s: String?): Date? {
if (s.isNullOrBlank()) return null
for (p in isoParsers) {
try {
return p.parse(s)
} catch (_: Exception) {
}
}
return null
}
/** "3h ago", "2d ago", "just now". */
fun relativeTime(s: String?): String {
val date = parseInstant(s) ?: return ""
val diff = System.currentTimeMillis() - date.time
if (diff < 0) return "just now"
val sec = diff / 1000
val min = sec / 60
val hr = min / 60
val day = hr / 24
return when {
sec < 45 -> "just now"
min < 60 -> "${min}m ago"
hr < 24 -> "${hr}h ago"
day < 30 -> "${day}d ago"
day < 365 -> "${day / 30}mo ago"
else -> "${day / 365}y ago"
}
}
private val shortDate = SimpleDateFormat("MMM d, yyyy", Locale.US)
private val timeOnly = SimpleDateFormat("h:mm a", Locale.US)
private val dayTime = SimpleDateFormat("MMM d, h:mm a", Locale.US)
fun shortDate(s: String?): String = parseInstant(s)?.let { shortDate.format(it) } ?: ""
fun clockTime(s: String?): String = parseInstant(s)?.let { timeOnly.format(it) } ?: ""
fun dayAndTime(s: String?): String = parseInstant(s)?.let { dayTime.format(it) } ?: ""
/** Bounty values render as plain numbers (no currency) prefixed by ◈ at the UI layer. */
fun formatBounty(value: Double): String =
if (value == value.toLong().toDouble()) value.toLong().toString()
else String.format(Locale.US, "%.2f", value)
fun formatMinutes(total: Int): String {
if (total <= 0) return "0m"
val h = total / 60
val m = total % 60
return when {
h > 0 && m > 0 -> "${h}h ${m}m"
h > 0 -> "${h}h"
else -> "${m}m"
}
}
fun statusLabel(status: String): String = when (status) {
"imported" -> "Imported"
"atomizing" -> "Atomizing"
"atomized" -> "Atomized"
"published" -> "Published"
"claim_requested" -> "Claim requested"
"assigned" -> "Assigned"
"in_progress" -> "In progress"
"in_review" -> "In review"
"changes_requested" -> "Changes requested"
"approved" -> "Approved"
"archived" -> "Archived"
else -> status.replace('_', ' ').replaceFirstChar { it.uppercase() }
}
/** Strip server-sanitized HTML to plain text for compact previews. */
fun htmlToPlain(html: String): String =
html.replace(Regex("<br\\s*/?>"), "\n")
.replace(Regex("</p>|</li>|</blockquote>"), "\n")
.replace(Regex("<[^>]+>"), "")
.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
.replace("&quot;", "\"").replace("&#39;", "'").replace("&nbsp;", " ")
.trim()
@@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#7A4A14" android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FDFBF6"
android:pathData="M54,30 L76,54 L54,78 L32,54 Z" />
<path
android:fillColor="#7A4A14"
android:pathData="M54,42 L66,54 L54,66 L42,54 Z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#7A4A14"
android:pathData="M54,28 L74,54 L54,80 L34,54 Z" />
<path
android:fillColor="#FDFBF6"
android:pathData="M54,40 L64,54 L54,68 L44,54 Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,3 L19,12 L12,21 L5,12 Z M12,7 L8,12 L12,17 L16,12 Z" />
</vector>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -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="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="brand_paper">#FDFBF6</color>
<color name="brand_ink">#33230E</color>
<color name="brand_accent">#7A4A14</color>
<color name="brand_surface2">#F2E9D8</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Bounty Board</string>
</resources>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.BountyBoard" parent="android:Theme.Material.NoActionBar" />
<style name="Theme.BountyBoard.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/brand_paper</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_diamond</item>
<item name="postSplashScreenTheme">@style/Theme.BountyBoard</item>
</style>
</resources>
+5
View File
@@ -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
}
+6
View File
@@ -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
+45
View File
@@ -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" }
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+252
View File
@@ -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" "$@"
Vendored
+94
View File
@@ -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
+23
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 = "BountyBoard"
include(":app")