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

||"), "\n") + .replace(Regex("<[^>]+>"), "") + .replace("&", "&").replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'").replace(" ", " ") + .trim() diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..b5fb3bc --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..226f4da --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_splash_diamond.xml b/app/src/main/res/drawable/ic_splash_diamond.xml new file mode 100644 index 0000000..d2ff636 --- /dev/null +++ b/app/src/main/res/drawable/ic_splash_diamond.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_stat_diamond.xml b/app/src/main/res/drawable/ic_stat_diamond.xml new file mode 100644 index 0000000..d33fc36 --- /dev/null +++ b/app/src/main/res/drawable/ic_stat_diamond.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/font/fraunces_bold.ttf b/app/src/main/res/font/fraunces_bold.ttf new file mode 100644 index 0000000..3647c4d Binary files /dev/null and b/app/src/main/res/font/fraunces_bold.ttf differ diff --git a/app/src/main/res/font/fraunces_regular.ttf b/app/src/main/res/font/fraunces_regular.ttf new file mode 100644 index 0000000..3351062 Binary files /dev/null and b/app/src/main/res/font/fraunces_regular.ttf differ diff --git a/app/src/main/res/font/fraunces_semibold.ttf b/app/src/main/res/font/fraunces_semibold.ttf new file mode 100644 index 0000000..2e50db3 Binary files /dev/null and b/app/src/main/res/font/fraunces_semibold.ttf differ diff --git a/app/src/main/res/font/schibsted_bold.ttf b/app/src/main/res/font/schibsted_bold.ttf new file mode 100644 index 0000000..c5a170f Binary files /dev/null and b/app/src/main/res/font/schibsted_bold.ttf differ diff --git a/app/src/main/res/font/schibsted_medium.ttf b/app/src/main/res/font/schibsted_medium.ttf new file mode 100644 index 0000000..9536c86 Binary files /dev/null and b/app/src/main/res/font/schibsted_medium.ttf differ diff --git a/app/src/main/res/font/schibsted_regular.ttf b/app/src/main/res/font/schibsted_regular.ttf new file mode 100644 index 0000000..7a4eefc Binary files /dev/null and b/app/src/main/res/font/schibsted_regular.ttf differ diff --git a/app/src/main/res/font/spline_mono_bold.ttf b/app/src/main/res/font/spline_mono_bold.ttf new file mode 100644 index 0000000..4fa536b Binary files /dev/null and b/app/src/main/res/font/spline_mono_bold.ttf differ diff --git a/app/src/main/res/font/spline_mono_medium.ttf b/app/src/main/res/font/spline_mono_medium.ttf new file mode 100644 index 0000000..286a456 Binary files /dev/null and b/app/src/main/res/font/spline_mono_medium.ttf differ diff --git a/app/src/main/res/font/spline_mono_regular.ttf b/app/src/main/res/font/spline_mono_regular.ttf new file mode 100644 index 0000000..197066a Binary files /dev/null and b/app/src/main/res/font/spline_mono_regular.ttf differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..abcda1d --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + #FDFBF6 + #33230E + #7A4A14 + #F2E9D8 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3fbb6e6 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Bounty Board + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4883068 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..9deb573 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..82379f9 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.caching=true +org.gradle.configuration-cache=true +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..3dc0fba --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,45 @@ +[versions] +agp = "8.5.2" +kotlin = "2.0.21" +coreKtx = "1.13.1" +lifecycle = "2.8.6" +activityCompose = "1.9.2" +composeBom = "2024.09.03" +navigation = "2.8.2" +retrofit = "2.11.0" +okhttp = "4.12.0" +coil = "2.7.0" +datastore = "1.1.1" +coroutines = "1.8.1" +splashscreen = "1.0.1" +work = "2.9.1" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } +androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..2c35211 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d312fdd --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "BountyBoard" +include(":app")