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:
+14
@@ -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.
@@ -0,0 +1,101 @@
|
||||
# Bounty Board — Android
|
||||
|
||||
A native Android client for the [Bounty Board](../BountyBoard) consulting work-management
|
||||
platform. Built with **Kotlin + Jetpack Compose (Material 3)**, it covers the full
|
||||
role-based feature set — admin, consultant, and developer — over the server's
|
||||
cookie-session + CSRF API.
|
||||
|
||||
The visual identity mirrors the web app's "ledger / bounty-poster" aesthetic: warm
|
||||
paper grounds, brown/amber ink, hard offset shadows (no blur), 2px corners, ◈ bounty
|
||||
chips, and the original **Fraunces / Schibsted Grotesk / Spline Sans Mono** typefaces
|
||||
(bundled, instanced from the web app's variable fonts).
|
||||
|
||||
## First run
|
||||
|
||||
The app opens on a **Login** page. The default server is
|
||||
`https://bountyboard.anypreta.com/`. Expand **Server** on the login screen to point the
|
||||
app at any other Bounty Board instance — the base URL is persisted and every request is
|
||||
retargeted at runtime. Sign in with email/password, register a developer account, or use
|
||||
"Forgot password". A forced password change is handled automatically when the server
|
||||
requires it.
|
||||
|
||||
## Features
|
||||
|
||||
- **Auth** — login (with configurable base URL), self-registration, forgot password,
|
||||
forced password change. Session + CSRF are handled transparently by a persistent
|
||||
cookie jar and a double-submit interceptor.
|
||||
- **Developer** — bounty board with search / project / min-bounty / sort filters and
|
||||
claim flow; My Tasks kanban (Assigned · In progress · In review · Done); per-task
|
||||
start / submit / abandon / withdraw; comments; time logging; metrics dashboard;
|
||||
leaderboard.
|
||||
- **Consultant** — atomization board (grouped by project, status filters, quick
|
||||
publish); subdivide / extend / edit / publish; reviews queue with an acceptance-
|
||||
criteria checklist; approve / decline claims; assign-to-AI; developer pool management;
|
||||
metrics.
|
||||
- **Admin** — users (roles, disable, force-reset, delete); projects/customers wizard
|
||||
(per-system credential fields, test connection, sync now); runtime settings; audit
|
||||
log; live service-status panel.
|
||||
- **Shared** — task detail with full role-aware action bar and timeline; real-time-feel
|
||||
messaging (DMs, group/project conversations, image + file attachments, read receipts);
|
||||
notification center with deep links; profile (avatar upload, bio/contact, theme
|
||||
picker with all six themes, leaderboard opt-out, sign-out-everywhere); public profile
|
||||
cards.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
data/ AppContainer (manual DI) · Repository · SessionManager · SettingsStore (DataStore)
|
||||
net/ Retrofit ApiService · PersistentCookieJar · CSRF + dynamic-host interceptors · DTOs
|
||||
model/ wire data classes
|
||||
ui/
|
||||
theme/ palette (6 themes) · typography · 2px shapes · hard-offset shadow modifier
|
||||
components/ BBButton · BBCard · BountyChip · StatusBadge · BBAvatar · BBTextField · dialogs · TaskCard
|
||||
nav/ AppRoot (bootstrap/auth gate) · MainShell (role drawer + NavHost) · Routes
|
||||
auth/ developer/ consultant/ admin/ task/ messaging/ shared/ profile/ feature screens + ViewModels
|
||||
```
|
||||
|
||||
- **Networking**: OkHttp + Retrofit (Gson). The base URL is dynamic — a host-selection
|
||||
interceptor rewrites scheme/host/port per request, so changing the server on the login
|
||||
screen takes effect immediately without rebuilding Retrofit. Coil shares the OkHttp
|
||||
stack so `/files/{id}` image loads carry the session cookie.
|
||||
- **Auth**: cookie-session only. `bb_session` (httpOnly) and `bb_csrf` are persisted in a
|
||||
`PersistentCookieJar`; mutating requests echo `bb_csrf` into the `X-CSRF-Token` header.
|
||||
- **State**: ViewModels expose Compose `mutableStateOf` snapshots; screens are stateless
|
||||
and driven by `LocalAppContainer`.
|
||||
|
||||
## Building
|
||||
|
||||
Requirements: **JDK 17 or 21** (AGP 8.5 does not support newer JDKs as the build JVM),
|
||||
the Android SDK with **platform 34** and **build-tools 34.0.0**, and Gradle 8.9+.
|
||||
|
||||
The easiest path is to open the project in **Android Studio** (Koala / 2024.1+), let it
|
||||
sync, and Run.
|
||||
|
||||
From the CLI (the Gradle wrapper is included; point `JAVA_HOME` at a JDK 17–21):
|
||||
|
||||
```bash
|
||||
cd BountyBoard_android
|
||||
JAVA_HOME=/path/to/jdk-21 ./gradlew assembleDebug # APK → app/build/outputs/apk/debug/
|
||||
JAVA_HOME=/path/to/jdk-21 ./gradlew installDebug # install on a connected device/emulator
|
||||
```
|
||||
|
||||
A prebuilt debug APK is committed at **`BountyBoard-debug.apk`** in the project root —
|
||||
`adb install -r BountyBoard-debug.apk` to side-load it directly.
|
||||
|
||||
`local.properties` already points `sdk.dir` at `~/Android/Sdk`; adjust if your SDK lives
|
||||
elsewhere. The app allows cleartext traffic so you can point it at a local `http://`
|
||||
dev server.
|
||||
|
||||
### Sign-in
|
||||
|
||||
Local email/password **and** OIDC SSO both work. "Sign in with SSO" opens the server's
|
||||
OIDC redirect flow in an in-app browser; once the IdP redirects back, the session cookies
|
||||
are captured into the app's HTTP stack and you're signed in. If the server has no OIDC
|
||||
configured, the app reports that instead of failing silently.
|
||||
|
||||
## Notes
|
||||
|
||||
- `minSdk 26`, `targetSdk 34`. No proprietary services; the launcher icon, splash, and
|
||||
fonts are all bundled.
|
||||
- The server contract is documented in `../BountyBoard/api/openapi.yaml` and
|
||||
`../BountyBoard/specification.md`; this client implements the §6 application API.
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+8
@@ -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.**
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace(""", "\"").replace("'", "'").replace(" ", " ")
|
||||
.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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Bounty Board</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user