Master split the repository into a monorepo (apps/web, apps/mobile) and added two-factor authentication. The organization files move into apps/web with the rest of the web app; the account settings list both Organizations and Two-factor auth. The organizations migration becomes 1791400000_orgs.js so it runs after master's 1791300000_two_factor.js instead of sharing its timestamp. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 402 changed files with 10843 additions and 209 deletions.
| @@ -1,11 +1,14 @@ | ||
| 1 | 1 | .git |
| 2 | - .next | |
| 3 | - node_modules | |
| 4 | - bin | |
| 5 | - data | |
| 2 | + **/node_modules | |
| 3 | + **/.next | |
| 4 | + **/*.tsbuildinfo | |
| 5 | + **/next-env.d.ts | |
| 6 | 6 | .env* |
| 7 | - *.tsbuildinfo | |
| 8 | - next-env.d.ts | |
| 9 | - Dockerfile | |
| 7 | + apps/web/bin | |
| 8 | + apps/web/data | |
| 9 | + apps/web/Dockerfile* | |
| 10 | + # Only the Android app's package.json, which the workspace's lockfile lists. | |
| 11 | + apps/mobile | |
| 12 | + !apps/mobile/package.json | |
| 10 | 13 | docker-compose.yml |
| 11 | 14 | .dockerignore |
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. |
| 2 | + # Each app in apps/ has its own .gitignore for what it builds. | |
| 2 | 3 | |
| 3 | 4 | # dependencies |
| 4 | - /node_modules | |
| 5 | - /.pnp | |
| 5 | + node_modules/ | |
| 6 | + .pnp | |
| 6 | 7 | .pnp.* |
| 7 | 8 | .yarn/* |
| 8 | 9 | !.yarn/patches |
| @@ -10,16 +11,6 @@ | ||
| 10 | 11 | !.yarn/releases |
| 11 | 12 | !.yarn/versions |
| 12 | 13 | |
| 13 | - # testing | |
| 14 | - /coverage | |
| 15 | - | |
| 16 | - # next.js | |
| 17 | - /.next/ | |
| 18 | - /out/ | |
| 19 | - | |
| 20 | - # production | |
| 21 | - /build | |
| 22 | - | |
| 23 | 14 | # misc |
| 24 | 15 | .DS_Store |
| 25 | 16 | *.pem |
| @@ -33,15 +24,6 @@ yarn-error.log* | ||
| 33 | 24 | # env files (can opt-in for committing if needed) |
| 34 | 25 | .env* |
| 35 | 26 | |
| 36 | - # vercel | |
| 37 | - .vercel | |
| 38 | - | |
| 39 | - # typescript | |
| 40 | - *.tsbuildinfo | |
| 41 | - next-env.d.ts | |
| 42 | - | |
| 43 | - # git repositories and issue data | |
| 44 | - /data/ | |
| 45 | - | |
| 46 | - # the PocketBase server binary (scripts/install-pocketbase.mjs) | |
| 47 | - /bin/ | |
| 27 | + # editors | |
| 28 | + .idea/ | |
| 29 | + *.iml | |
| @@ -1,9 +1,11 @@ | ||
| 1 | - <!-- BEGIN:nextjs-agent-rules --> | |
| 1 | + # Vectrum monorepo | |
| 2 | 2 | |
| 3 | - # This is NOT the Next.js you know | |
| 3 | + Bun workspaces, one app per folder in `apps/`: | |
| 4 | 4 | |
| 5 | - This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. | |
| 5 | + - `apps/web`: the forge itself, a Next.js app (website, git over SSH and HTTPS, MCP, the `/api/v1` JSON API, runners' | |
| 6 | + API). Read `apps/web/AGENTS.md` before changing it. | |
| 7 | + - `apps/mobile`: the Android app, in Kotlin with Jetpack Compose, built with Gradle (`apps/mobile/gradlew`). It only | |
| 8 | + talks to the web app over `/api/v1` (`apps/web/lib/app-api.ts`); keep both sides in step when changing that API. | |
| 6 | 9 | |
| 7 | - This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. | |
| 8 | - | |
| 9 | - <!-- END:nextjs-agent-rules --> | |
| 10 | + Run commands from the repository root (`bun dev`, `bun run lint`, `bun run test`, `bun run mobile:build`), or inside an | |
| 11 | + app's folder. Product documentation is in `docs/`. | |
| @@ -8,10 +8,15 @@ Everything they do is recorded as theirs and can be reverted in one click. Peopl | ||
| 8 | 8 | decide what reaches protected branches. |
| 9 | 9 | |
| 10 | 10 | Vectrum comes with issues, pull requests, forks, wikis, releases, a container |
| 11 | - registry, and CI on self-hosted runners. See [all features](docs/features.md). | |
| 11 | + registry, CI on self-hosted runners, and an Android app. See [all features](docs/features.md). | |
| 12 | 12 | |
| 13 | 13 | Website: [vectrum.cc](https://vectrum.cc) |
| 14 | 14 | |
| 15 | + This repository holds both apps, as Bun workspaces: | |
| 16 | + | |
| 17 | + - [`apps/web`](apps/web): the forge, a Next.js app | |
| 18 | + - [`apps/mobile`](apps/mobile): the Android app, in Kotlin with Jetpack Compose | |
| 19 | + | |
| 15 | 20 | ## Getting started |
| 16 | 21 | |
| 17 | 22 | Requires Node.js 22+ and `git`. |
| @@ -23,6 +28,13 @@ bun dev | ||
| 23 | 28 | |
| 24 | 29 | Open http://localhost:3000, sign up, and create a repository. |
| 25 | 30 | |
| 31 | + To build the Android app (needs JDK 17+ and the Android SDK, see [mobile.md](docs/mobile.md)): | |
| 32 | + | |
| 33 | + ```bash | |
| 34 | + bun run mobile:build # apps/mobile/app/build/outputs/apk/debug/app-debug.apk | |
| 35 | + bun run mobile:deploy # onto a connected device or emulator | |
| 36 | + ``` | |
| 37 | + | |
| 26 | 38 | To run it with Docker: |
| 27 | 39 | |
| 28 | 40 | ```bash |
| @@ -35,9 +47,11 @@ docker compose up -d --build | ||
| 35 | 47 | - [Using Vectrum](docs/usage.md): git, container images, wikis, releases, forks |
| 36 | 48 | - [Actions](docs/actions.md): workflows and runners |
| 37 | 49 | - [Agents](docs/agents.md): MCP, the command line, events and webhooks |
| 50 | + - [Android app](docs/mobile.md): building it, and the API it uses | |
| 38 | 51 | - [Security](docs/security.md) |
| 39 | 52 | - [Code layout](docs/layout.md) |
| 40 | 53 | |
| 41 | 54 | ## Contributing |
| 42 | 55 | |
| 43 | - Run `bun run lint` and `bun test` before opening a pull request. | |
| 56 | + Run `bun run lint` and `bun run test` before opening a pull request, and `bun run mobile:build` if you changed the | |
| 57 | + Android app or the API it uses (`apps/web/lib/app-api.ts`). | |
| @@ -1,36 +0,0 @@ | ||
| 1 | - "use server"; | |
| 2 | - | |
| 3 | - import { redirect } from "next/navigation"; | |
| 4 | - import { endSession, safeNext, signIn } from "@/lib/auth"; | |
| 5 | - import { SIGNUPS_ENABLED } from "@/lib/signups"; | |
| 6 | - import { createUser, UserError } from "@/lib/users"; | |
| 7 | - import type { FormState } from "./types"; | |
| 8 | - | |
| 9 | - export async function signupAction(_prev: FormState, formData: FormData): Promise<FormState> { | |
| 10 | - if (!SIGNUPS_ENABLED) return { error: "Sign-ups are closed." }; | |
| 11 | - const password = String(formData.get("password") ?? ""); | |
| 12 | - try { | |
| 13 | - const user = await createUser({ | |
| 14 | - username: String(formData.get("username") ?? ""), | |
| 15 | - email: String(formData.get("email") ?? ""), | |
| 16 | - password, | |
| 17 | - name: String(formData.get("name") ?? ""), | |
| 18 | - }); | |
| 19 | - await signIn(user.email, password); | |
| 20 | - } catch (e) { | |
| 21 | - if (e instanceof UserError) return { error: e.message }; | |
| 22 | - throw e; | |
| 23 | - } | |
| 24 | - redirect(safeNext(formData.get("next"))); | |
| 25 | - } | |
| 26 | - | |
| 27 | - export async function loginAction(_prev: FormState, formData: FormData): Promise<FormState> { | |
| 28 | - const user = await signIn(String(formData.get("login") ?? ""), String(formData.get("password") ?? "")); | |
| 29 | - if (!user) return { error: "Incorrect username or password." }; | |
| 30 | - redirect(safeNext(formData.get("next"))); | |
| 31 | - } | |
| 32 | - | |
| 33 | - export async function logoutAction() { | |
| 34 | - await endSession(); | |
| 35 | - redirect("/"); | |
| 36 | - } | |
| @@ -0,0 +1,7 @@ | ||
| 1 | + .gradle/ | |
| 2 | + .kotlin/ | |
| 3 | + build/ | |
| 4 | + local.properties | |
| 5 | + *.iml | |
| 6 | + .idea/ | |
| 7 | + captures/ | |
| @@ -0,0 +1,61 @@ | ||
| 1 | + plugins { | |
| 2 | + alias(libs.plugins.android.application) | |
| 3 | + alias(libs.plugins.kotlin.compose) | |
| 4 | + alias(libs.plugins.kotlin.serialization) | |
| 5 | + } | |
| 6 | + | |
| 7 | + android { | |
| 8 | + namespace = "cc.vectrum.app" | |
| 9 | + compileSdk = 37 | |
| 10 | + | |
| 11 | + defaultConfig { | |
| 12 | + applicationId = "cc.vectrum.app" | |
| 13 | + minSdk = 26 | |
| 14 | + targetSdk = 36 | |
| 15 | + versionCode = 1 | |
| 16 | + versionName = "1.0.0" | |
| 17 | + } | |
| 18 | + | |
| 19 | + buildTypes { | |
| 20 | + release { | |
| 21 | + isMinifyEnabled = true | |
| 22 | + isShrinkResources = true | |
| 23 | + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") | |
| 24 | + // Signed with the debug key so `assembleRelease` gives an installable APK; use your own key to publish. | |
| 25 | + signingConfig = signingConfigs.getByName("debug") | |
| 26 | + } | |
| 27 | + } | |
| 28 | + | |
| 29 | + buildFeatures { | |
| 30 | + compose = true | |
| 31 | + buildConfig = true | |
| 32 | + } | |
| 33 | + | |
| 34 | + compileOptions { | |
| 35 | + sourceCompatibility = JavaVersion.VERSION_17 | |
| 36 | + targetCompatibility = JavaVersion.VERSION_17 | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + dependencies { | |
| 41 | + implementation(libs.androidx.core) | |
| 42 | + implementation(libs.androidx.splashscreen) | |
| 43 | + implementation(libs.androidx.activity.compose) | |
| 44 | + implementation(libs.androidx.lifecycle.runtime.compose) | |
| 45 | + implementation(libs.androidx.lifecycle.viewmodel.compose) | |
| 46 | + implementation(libs.androidx.navigation.compose) | |
| 47 | + implementation(libs.androidx.datastore.preferences) | |
| 48 | + | |
| 49 | + implementation(platform(libs.compose.bom)) | |
| 50 | + implementation(libs.compose.ui) | |
| 51 | + implementation(libs.compose.foundation) | |
| 52 | + implementation(libs.compose.material3) | |
| 53 | + implementation(libs.compose.ui.tooling.preview) | |
| 54 | + debugImplementation(libs.compose.ui.tooling) | |
| 55 | + | |
| 56 | + implementation(libs.kotlinx.coroutines.android) | |
| 57 | + implementation(libs.kotlinx.serialization.json) | |
| 58 | + implementation(libs.okhttp) | |
| 59 | + implementation(libs.coil.compose) | |
| 60 | + implementation(libs.coil.network.okhttp) | |
| 61 | + } | |
| @@ -0,0 +1,2 @@ | ||
| 1 | + # kotlinx.serialization keeps what it needs through its own consumer rules; the API models are | |
| 2 | + # @Serializable classes, so nothing extra is required here. | |
| @@ -0,0 +1,25 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | |
| 3 | + | |
| 4 | + <uses-permission android:name="android.permission.INTERNET" /> | |
| 5 | + | |
| 6 | + <application | |
| 7 | + android:name=".VectrumApp" | |
| 8 | + android:allowBackup="false" | |
| 9 | + android:icon="@mipmap/ic_launcher" | |
| 10 | + android:roundIcon="@mipmap/ic_launcher" | |
| 11 | + android:label="@string/app_name" | |
| 12 | + android:supportsRtl="true" | |
| 13 | + android:networkSecurityConfig="@xml/network_security_config" | |
| 14 | + android:theme="@style/Theme.Vectrum.Starting"> | |
| 15 | + <activity | |
| 16 | + android:name=".MainActivity" | |
| 17 | + android:exported="true" | |
| 18 | + android:windowSoftInputMode="adjustResize"> | |
| 19 | + <intent-filter> | |
| 20 | + <action android:name="android.intent.action.MAIN" /> | |
| 21 | + <category android:name="android.intent.category.LAUNCHER" /> | |
| 22 | + </intent-filter> | |
| 23 | + </activity> | |
| 24 | + </application> | |
| 25 | + </manifest> | |
| @@ -0,0 +1,101 @@ | ||
| 1 | + package cc.vectrum.app | |
| 2 | + | |
| 3 | + import android.graphics.Color | |
| 4 | + import android.os.Bundle | |
| 5 | + import androidx.activity.ComponentActivity | |
| 6 | + import androidx.activity.SystemBarStyle | |
| 7 | + import androidx.activity.compose.setContent | |
| 8 | + import androidx.activity.enableEdgeToEdge | |
| 9 | + import androidx.compose.foundation.isSystemInDarkTheme | |
| 10 | + import androidx.compose.runtime.Composable | |
| 11 | + import androidx.compose.runtime.DisposableEffect | |
| 12 | + import androidx.compose.runtime.SideEffect | |
| 13 | + import androidx.compose.runtime.getValue | |
| 14 | + import androidx.compose.runtime.remember | |
| 15 | + import androidx.compose.runtime.rememberCoroutineScope | |
| 16 | + import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen | |
| 17 | + import androidx.lifecycle.compose.collectAsStateWithLifecycle | |
| 18 | + import cc.vectrum.app.data.Api | |
| 19 | + import cc.vectrum.app.data.Settings | |
| 20 | + import cc.vectrum.app.data.normalizeServer | |
| 21 | + import cc.vectrum.app.ui.MainShell | |
| 22 | + import cc.vectrum.app.ui.SignedOutShell | |
| 23 | + import cc.vectrum.app.ui.screens.SetupScreen | |
| 24 | + import cc.vectrum.app.ui.theme.ThemeMode | |
| 25 | + import cc.vectrum.app.ui.theme.VectrumTheme | |
| 26 | + import coil3.SingletonImageLoader | |
| 27 | + import kotlinx.coroutines.flow.map | |
| 28 | + import kotlinx.coroutines.launch | |
| 29 | + | |
| 30 | + class MainActivity : ComponentActivity() { | |
| 31 | + override fun onCreate(savedInstanceState: Bundle?) { | |
| 32 | + val app = application as VectrumApp | |
| 33 | + val splash = installSplashScreen() | |
| 34 | + super.onCreate(savedInstanceState) | |
| 35 | + enableEdgeToEdge() | |
| 36 | + | |
| 37 | + // Keep the splash up until the stored session is read, so the app opens on the right screen. | |
| 38 | + var ready = false | |
| 39 | + splash.setKeepOnScreenCondition { !ready } | |
| 40 | + | |
| 41 | + setContent { | |
| 42 | + val settings by remember { app.store.settings.map<Settings, Settings?> { it } }.collectAsStateWithLifecycle(initialValue = null) | |
| 43 | + val current = settings ?: return@setContent | |
| 44 | + SideEffect { ready = true } | |
| 45 | + VectrumTheme(current.theme) { | |
| 46 | + SystemBars(current.theme) | |
| 47 | + Root(app, current) | |
| 48 | + } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + /** Light status bar icons on graphite, dark ones on bone. */ | |
| 53 | + @Composable | |
| 54 | + private fun SystemBars(theme: ThemeMode) { | |
| 55 | + val dark = when (theme) { | |
| 56 | + ThemeMode.System -> isSystemInDarkTheme() | |
| 57 | + ThemeMode.Bone -> false | |
| 58 | + ThemeMode.Graphite -> true | |
| 59 | + } | |
| 60 | + DisposableEffect(dark) { | |
| 61 | + val style = if (dark) SystemBarStyle.dark(Color.TRANSPARENT) else SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT) | |
| 62 | + enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style) | |
| 63 | + onDispose {} | |
| 64 | + } | |
| 65 | + } | |
| 66 | + } | |
| 67 | + | |
| 68 | + @Composable | |
| 69 | + private fun Root(app: VectrumApp, settings: Settings) { | |
| 70 | + val scope = rememberCoroutineScope() | |
| 71 | + val session = settings.session | |
| 72 | + val server = session?.let { normalizeServer(it.server) } | |
| 73 | + if (session == null || server == null) { | |
| 74 | + app.imageAuth = null | |
| 75 | + SignedOutShell { | |
| 76 | + SetupScreen( | |
| 77 | + lastServer = settings.lastServer, | |
| 78 | + onboarded = settings.onboarded, | |
| 79 | + http = app.http, | |
| 80 | + onSignedIn = { scope.launch { app.store.signIn(it) } }, | |
| 81 | + ) | |
| 82 | + } | |
| 83 | + return | |
| 84 | + } | |
| 85 | + val api = remember(session.server, session.token) { Api(app.http, server, session.token) } | |
| 86 | + app.imageAuth = api.origin to session.token | |
| 87 | + MainShell( | |
| 88 | + session = session, | |
| 89 | + api = api, | |
| 90 | + theme = settings.theme, | |
| 91 | + onTheme = { scope.launch { app.store.setTheme(it) } }, | |
| 92 | + onSignOut = { | |
| 93 | + // Images of private repositories don't outlive the session. | |
| 94 | + SingletonImageLoader.get(app).let { loader -> | |
| 95 | + loader.memoryCache?.clear() | |
| 96 | + loader.diskCache?.clear() | |
| 97 | + } | |
| 98 | + scope.launch { app.store.signOut() } | |
| 99 | + }, | |
| 100 | + ) | |
| 101 | + } | |
| @@ -0,0 +1,45 @@ | ||
| 1 | + package cc.vectrum.app | |
| 2 | + | |
| 3 | + import android.app.Application | |
| 4 | + import cc.vectrum.app.data.Api | |
| 5 | + import cc.vectrum.app.data.SessionStore | |
| 6 | + import coil3.ImageLoader | |
| 7 | + import coil3.PlatformContext | |
| 8 | + import coil3.SingletonImageLoader | |
| 9 | + import coil3.network.okhttp.OkHttpNetworkFetcherFactory | |
| 10 | + import okhttp3.Interceptor | |
| 11 | + import okhttp3.OkHttpClient | |
| 12 | + | |
| 13 | + class VectrumApp : Application(), SingletonImageLoader.Factory { | |
| 14 | + lateinit var store: SessionStore | |
| 15 | + private set | |
| 16 | + | |
| 17 | + /** The current session's server and token, for images: Coil's requests carry the token only to that server. */ | |
| 18 | + @Volatile | |
| 19 | + var imageAuth: Pair<String, String>? = null | |
| 20 | + | |
| 21 | + /** One HTTP client for the API and for images. */ | |
| 22 | + val http: OkHttpClient by lazy { Api.client() } | |
| 23 | + | |
| 24 | + override fun onCreate() { | |
| 25 | + super.onCreate() | |
| 26 | + store = SessionStore(this) | |
| 27 | + } | |
| 28 | + | |
| 29 | + override fun newImageLoader(context: PlatformContext): ImageLoader { | |
| 30 | + val authorizing = Interceptor { chain -> | |
| 31 | + val request = chain.request() | |
| 32 | + val auth = imageAuth | |
| 33 | + val url = request.url.toString() | |
| 34 | + if (auth != null && url.startsWith(auth.first.trimEnd('/') + "/api/v1/")) { | |
| 35 | + chain.proceed(request.newBuilder().header("Authorization", "Bearer ${auth.second}").build()) | |
| 36 | + } else { | |
| 37 | + chain.proceed(request) | |
| 38 | + } | |
| 39 | + } | |
| 40 | + val client = http.newBuilder().addInterceptor(authorizing).build() | |
| 41 | + return ImageLoader.Builder(context) | |
| 42 | + .components { add(OkHttpNetworkFetcherFactory(callFactory = { client })) } | |
| 43 | + .build() | |
| 44 | + } | |
| 45 | + } | |
| @@ -0,0 +1,191 @@ | ||
| 1 | + package cc.vectrum.app.data | |
| 2 | + | |
| 3 | + import kotlinx.coroutines.Dispatchers | |
| 4 | + import kotlinx.coroutines.suspendCancellableCoroutine | |
| 5 | + import kotlinx.coroutines.withContext | |
| 6 | + import kotlinx.serialization.json.Json | |
| 7 | + import kotlinx.serialization.json.JsonObject | |
| 8 | + import kotlinx.serialization.json.JsonPrimitive | |
| 9 | + import kotlinx.serialization.json.buildJsonObject | |
| 10 | + import kotlinx.serialization.json.jsonObject | |
| 11 | + import kotlinx.serialization.json.jsonPrimitive | |
| 12 | + import okhttp3.Call | |
| 13 | + import okhttp3.Callback | |
| 14 | + import okhttp3.HttpUrl | |
| 15 | + import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | |
| 16 | + import okhttp3.MediaType.Companion.toMediaType | |
| 17 | + import okhttp3.OkHttpClient | |
| 18 | + import okhttp3.Request | |
| 19 | + import okhttp3.RequestBody.Companion.toRequestBody | |
| 20 | + import okhttp3.Response | |
| 21 | + import java.io.IOException | |
| 22 | + import java.util.concurrent.TimeUnit | |
| 23 | + import kotlin.coroutines.resumeWithException | |
| 24 | + | |
| 25 | + /** What went wrong talking to the server, in words a person can act on. */ | |
| 26 | + class ApiException(val status: Int, message: String, val twoFactor: Boolean = false) : Exception(message) { | |
| 27 | + val isUnauthorized: Boolean get() = status == 401 | |
| 28 | + } | |
| 29 | + | |
| 30 | + val json = Json { | |
| 31 | + ignoreUnknownKeys = true | |
| 32 | + explicitNulls = false | |
| 33 | + coerceInputValues = true | |
| 34 | + } | |
| 35 | + | |
| 36 | + /** The normalized address of a Vectrum server, or null if it isn't one: "vectrum.cc" → https://vectrum.cc. */ | |
| 37 | + fun normalizeServer(input: String): HttpUrl? { | |
| 38 | + val trimmed = input.trim().trimEnd('/') | |
| 39 | + if (trimmed.isEmpty()) return null | |
| 40 | + val withScheme = if (trimmed.contains("://")) trimmed else "https://$trimmed" | |
| 41 | + val url = withScheme.toHttpUrlOrNull() ?: return null | |
| 42 | + return url.newBuilder().query(null).fragment(null).build() | |
| 43 | + } | |
| 44 | + | |
| 45 | + /** Whether a server is on this device, the emulator's host or a private network, where plain HTTP is common. */ | |
| 46 | + fun isLocalHost(url: HttpUrl): Boolean { | |
| 47 | + val h = url.host | |
| 48 | + return h == "localhost" || h == "10.0.2.2" || h.endsWith(".local") || h.startsWith("127.") || h.startsWith("192.168.") || | |
| 49 | + h.startsWith("10.") || Regex("^172\\.(1[6-9]|2\\d|3[01])\\.").containsMatchIn(h) | |
| 50 | + } | |
| 51 | + | |
| 52 | + /** | |
| 53 | + * The server's JSON API (lib/app-api.ts on the server), signed in with the session token it hands out. | |
| 54 | + * One client for all requests: OkHttp shares connections, and Coil loads images through it too. | |
| 55 | + */ | |
| 56 | + class Api(val http: OkHttpClient, private val server: HttpUrl, private val token: String?) { | |
| 57 | + val origin: String get() = server.toString().trimEnd('/') | |
| 58 | + | |
| 59 | + /** Path segments are added one by one, so OkHttp encodes each (names, refs and SHAs never contain "/"). */ | |
| 60 | + private fun url(path: String, query: Map<String, String?> = emptyMap()): HttpUrl { | |
| 61 | + val b = server.newBuilder() | |
| 62 | + for (segment in "api/v1/$path".split('/')) if (segment.isNotEmpty()) b.addPathSegment(segment) | |
| 63 | + for ((k, v) in query) if (v != null) b.addQueryParameter(k, v) | |
| 64 | + return b.build() | |
| 65 | + } | |
| 66 | + | |
| 67 | + private fun request(url: HttpUrl) = Request.Builder().url(url).header("Accept", "application/json").apply { | |
| 68 | + if (token != null) header("Authorization", "Bearer $token") | |
| 69 | + } | |
| 70 | + | |
| 71 | + private suspend fun call(request: Request): String = withContext(Dispatchers.IO) { | |
| 72 | + val response = http.newCall(request).await() | |
| 73 | + response.use { r -> | |
| 74 | + val body = r.body.string() | |
| 75 | + if (!r.isSuccessful) { | |
| 76 | + val fields = runCatching { json.parseToJsonElement(body).jsonObject }.getOrNull() | |
| 77 | + val message = fields?.get("error")?.let { runCatching { it.jsonPrimitive.content }.getOrNull() } | |
| 78 | + // A sign-in to an account with two-factor authentication on, that needs a code. | |
| 79 | + val twoFactor = fields?.get("twoFactor")?.let { runCatching { it.jsonPrimitive.content == "true" }.getOrNull() } ?: false | |
| 80 | + throw ApiException(r.code, message ?: "The server answered ${r.code}.", twoFactor) | |
| 81 | + } | |
| 82 | + body | |
| 83 | + } | |
| 84 | + } | |
| 85 | + | |
| 86 | + private suspend inline fun <reified T> get(path: String, query: Map<String, String?> = emptyMap()): T = | |
| 87 | + json.decodeFromString(call(request(url(path, query)).get().build())) | |
| 88 | + | |
| 89 | + private suspend inline fun <reified T> send(method: String, path: String, body: JsonObject): T = | |
| 90 | + json.decodeFromString(call(request(url(path)).method(method, body.toString().toRequestBody(JSON)).build())) | |
| 91 | + | |
| 92 | + /** The address of a path on the server, for images and links. */ | |
| 93 | + fun apiUrl(path: String, query: Map<String, String?> = emptyMap()): String = url(path, query).toString() | |
| 94 | + | |
| 95 | + /** [code] is from the authenticator app (or a recovery code), for accounts with two-factor authentication on. */ | |
| 96 | + suspend fun signIn(login: String, password: String, code: String? = null): SessionResponse = | |
| 97 | + send( | |
| 98 | + "POST", | |
| 99 | + "session", | |
| 100 | + buildJsonObject { | |
| 101 | + put("login", JsonPrimitive(login)) | |
| 102 | + put("password", JsonPrimitive(password)) | |
| 103 | + if (code != null) put("code", JsonPrimitive(code)) | |
| 104 | + }, | |
| 105 | + ) | |
| 106 | + | |
| 107 | + suspend fun signUp(username: String, email: String, password: String, name: String): SessionResponse = | |
| 108 | + send( | |
| 109 | + "POST", | |
| 110 | + "signup", | |
| 111 | + buildJsonObject { | |
| 112 | + put("username", JsonPrimitive(username)) | |
| 113 | + put("email", JsonPrimitive(email)) | |
| 114 | + put("password", JsonPrimitive(password)) | |
| 115 | + put("name", JsonPrimitive(name)) | |
| 116 | + }, | |
| 117 | + ) | |
| 118 | + | |
| 119 | + suspend fun serverInfo(): ServerInfo = get("server") | |
| 120 | + | |
| 121 | + /** Whether the server answers its liveness check (true for any Vectrum, also one without the app's API). */ | |
| 122 | + suspend fun healthy(): Boolean = runCatching { call(request(server.newBuilder().addPathSegments("api/health").build()).get().build()) }.isSuccess | |
| 123 | + | |
| 124 | + suspend fun me(): User = get<UserResponse>("user").user | |
| 125 | + suspend fun home(): HomeData = get("home") | |
| 126 | + suspend fun explore(query: String): ReposData = get("explore", mapOf("q" to query)) | |
| 127 | + suspend fun profile(username: String): ProfileData = get("users/${username}") | |
| 128 | + | |
| 129 | + private fun repo(owner: String, name: String) = "repos/${owner}/${name}" | |
| 130 | + | |
| 131 | + suspend fun repository(owner: String, name: String): RepoDetail = get(repo(owner, name)) | |
| 132 | + suspend fun tree(owner: String, name: String, ref: String?, path: String): TreeData = get("${repo(owner, name)}/tree", mapOf("ref" to ref, "path" to path)) | |
| 133 | + suspend fun blob(owner: String, name: String, ref: String?, path: String): BlobData = get("${repo(owner, name)}/blob", mapOf("ref" to ref, "path" to path)) | |
| 134 | + fun rawUrl(owner: String, name: String, ref: String?, path: String) = apiUrl("${repo(owner, name)}/raw", mapOf("ref" to ref, "path" to path)) | |
| 135 | + fun repoImageUrl(owner: String, name: String, version: Long?) = apiUrl("${repo(owner, name)}/image", mapOf("v" to version?.toString())) | |
| 136 | + suspend fun commits(owner: String, name: String, ref: String?, page: Int, path: String? = null): CommitsData = | |
| 137 | + get("${repo(owner, name)}/commits", mapOf("ref" to ref, "page" to page.toString(), "path" to path)) | |
| 138 | + suspend fun commit(owner: String, name: String, sha: String): CommitDetail = get("${repo(owner, name)}/commits/${sha}") | |
| 139 | + suspend fun branches(owner: String, name: String): BranchesData = get("${repo(owner, name)}/branches") | |
| 140 | + suspend fun repoEvents(owner: String, name: String): EventsData = get("${repo(owner, name)}/events") | |
| 141 | + | |
| 142 | + suspend fun issues(owner: String, name: String): IssuesData = get("${repo(owner, name)}/issues") | |
| 143 | + suspend fun issue(owner: String, name: String, number: Int): IssueDetail = get("${repo(owner, name)}/issues/$number") | |
| 144 | + suspend fun createIssue(owner: String, name: String, fields: JsonObject): CreatedIssue = send("POST", "${repo(owner, name)}/issues", fields) | |
| 145 | + suspend fun updateIssue(owner: String, name: String, number: Int, patch: JsonObject): IssueDetail = send("PATCH", "${repo(owner, name)}/issues/$number", patch) | |
| 146 | + suspend fun commentOnIssue(owner: String, name: String, number: Int, body: String): IssueDetail = | |
| 147 | + send("POST", "${repo(owner, name)}/issues/$number/comments", buildJsonObject { put("body", JsonPrimitive(body)) }) | |
| 148 | + | |
| 149 | + suspend fun pulls(owner: String, name: String, state: String): PullsData = get("${repo(owner, name)}/pulls", mapOf("state" to state)) | |
| 150 | + suspend fun pull(owner: String, name: String, number: Int): PullDetail = get("${repo(owner, name)}/pulls/$number") | |
| 151 | + suspend fun pullFiles(owner: String, name: String, number: Int): FilesData = get("${repo(owner, name)}/pulls/$number/files") | |
| 152 | + suspend fun commentOnPull(owner: String, name: String, number: Int, body: String): PullDetail = | |
| 153 | + send("POST", "${repo(owner, name)}/pulls/$number/comments", buildJsonObject { put("body", JsonPrimitive(body)) }) | |
| 154 | + suspend fun reviewPull(owner: String, name: String, number: Int, state: String, body: String): PullDetail = | |
| 155 | + send("POST", "${repo(owner, name)}/pulls/$number/reviews", buildJsonObject { put("state", JsonPrimitive(state)); put("body", JsonPrimitive(body)) }) | |
| 156 | + suspend fun updatePull(owner: String, name: String, number: Int, patch: JsonObject): PullDetail = send("PATCH", "${repo(owner, name)}/pulls/$number", patch) | |
| 157 | + suspend fun mergePull(owner: String, name: String, number: Int, method: String, deleteBranch: Boolean, headSha: String): PullDetail = | |
| 158 | + send( | |
| 159 | + "POST", | |
| 160 | + "${repo(owner, name)}/pulls/$number/merge", | |
| 161 | + buildJsonObject { | |
| 162 | + put("method", JsonPrimitive(method)) | |
| 163 | + put("deleteBranch", JsonPrimitive(deleteBranch)) | |
| 164 | + put("headSha", JsonPrimitive(headSha)) | |
| 165 | + }, | |
| 166 | + ) | |
| 167 | + | |
| 168 | + suspend fun runs(owner: String, name: String, page: Int): RunsData = get("${repo(owner, name)}/runs", mapOf("page" to page.toString())) | |
| 169 | + suspend fun run(owner: String, name: String, number: Int): RunDetail = get("${repo(owner, name)}/runs/$number") | |
| 170 | + suspend fun releases(owner: String, name: String): ReleasesData = get("${repo(owner, name)}/releases") | |
| 171 | + | |
| 172 | + companion object { | |
| 173 | + private val JSON = "application/json; charset=utf-8".toMediaType() | |
| 174 | + | |
| 175 | + fun client(): OkHttpClient = OkHttpClient.Builder() | |
| 176 | + .connectTimeout(15, TimeUnit.SECONDS) | |
| 177 | + .readTimeout(60, TimeUnit.SECONDS) | |
| 178 | + .build() | |
| 179 | + } | |
| 180 | + } | |
| 181 | + | |
| 182 | + /** Awaits an OkHttp call, cancelling it with the coroutine. */ | |
| 183 | + private suspend fun Call.await(): Response = suspendCancellableCoroutine { cont -> | |
| 184 | + enqueue(object : Callback { | |
| 185 | + override fun onResponse(call: Call, response: Response) = cont.resume(response) { _, _, _ -> response.close() } | |
| 186 | + override fun onFailure(call: Call, e: IOException) { | |
| 187 | + if (!cont.isCancelled) cont.resumeWithException(e) | |
| 188 | + } | |
| 189 | + }) | |
| 190 | + cont.invokeOnCancellation { runCatching { cancel() } } | |
| 191 | + } | |
| @@ -0,0 +1,356 @@ | ||
| 1 | + package cc.vectrum.app.data | |
| 2 | + | |
| 3 | + import kotlinx.serialization.Serializable | |
| 4 | + import kotlinx.serialization.json.JsonObject | |
| 5 | + | |
| 6 | + // The shapes of the server's /api/v1 answers (lib/app-api.ts). | |
| 7 | + | |
| 8 | + @Serializable | |
| 9 | + data class User( | |
| 10 | + val id: String, | |
| 11 | + val username: String, | |
| 12 | + val name: String = "", | |
| 13 | + val email: String = "", | |
| 14 | + val bio: String = "", | |
| 15 | + val createdAt: Long = 0, | |
| 16 | + ) { | |
| 17 | + val displayName: String get() = name.ifBlank { username } | |
| 18 | + } | |
| 19 | + | |
| 20 | + @Serializable data class SessionResponse(val token: String, val user: User) | |
| 21 | + | |
| 22 | + /** What a server says about itself before anyone signs in (GET /api/v1/server). */ | |
| 23 | + @Serializable data class ServerInfo(val name: String = "Vectrum", val api: Int = 1, val signups: Boolean = false) | |
| 24 | + @Serializable data class UserResponse(val user: User) | |
| 25 | + | |
| 26 | + @Serializable data class RepoRef(val owner: String, val name: String) | |
| 27 | + | |
| 28 | + @Serializable | |
| 29 | + data class RepoSummary( | |
| 30 | + val id: String, | |
| 31 | + val owner: String, | |
| 32 | + val name: String, | |
| 33 | + val description: String = "", | |
| 34 | + val visibility: String = "public", | |
| 35 | + val forkOf: RepoRef? = null, | |
| 36 | + val isFork: Boolean = false, | |
| 37 | + val hasImage: Boolean = false, | |
| 38 | + val imageVersion: Long? = null, | |
| 39 | + val createdAt: Long = 0, | |
| 40 | + val updatedAt: Long = 0, | |
| 41 | + val empty: Boolean = false, | |
| 42 | + ) { | |
| 43 | + val isPrivate: Boolean get() = visibility == "private" | |
| 44 | + val fullName: String get() = "$owner/$name" | |
| 45 | + } | |
| 46 | + | |
| 47 | + @Serializable data class CommitStatus(val status: String, val runNumber: Int = 0) | |
| 48 | + | |
| 49 | + @Serializable | |
| 50 | + data class CommitInfo( | |
| 51 | + val sha: String, | |
| 52 | + val parents: List<String> = emptyList(), | |
| 53 | + val authorName: String = "", | |
| 54 | + val authorEmail: String = "", | |
| 55 | + val committerName: String = "", | |
| 56 | + val date: Long = 0, | |
| 57 | + val subject: String = "", | |
| 58 | + val body: String = "", | |
| 59 | + /** The agent that made the commit for its author. */ | |
| 60 | + val agent: String? = null, | |
| 61 | + val status: CommitStatus? = null, | |
| 62 | + ) | |
| 63 | + | |
| 64 | + @Serializable data class EventTarget(val type: String, val number: Int? = null, val sha: String? = null) | |
| 65 | + | |
| 66 | + @Serializable | |
| 67 | + data class Event( | |
| 68 | + val id: String, | |
| 69 | + val kind: String, | |
| 70 | + val action: String? = null, | |
| 71 | + val actor: String? = null, | |
| 72 | + val agent: String? = null, | |
| 73 | + val text: String, | |
| 74 | + val createdAt: Long, | |
| 75 | + val repo: RepoRef? = null, | |
| 76 | + val target: EventTarget? = null, | |
| 77 | + ) | |
| 78 | + | |
| 79 | + @Serializable data class RepoCommit(val repo: RepoRef, val commit: CommitInfo) | |
| 80 | + | |
| 81 | + @Serializable | |
| 82 | + data class HomeData( | |
| 83 | + val user: User, | |
| 84 | + val repos: List<RepoSummary>, | |
| 85 | + val openIssues: Int = 0, | |
| 86 | + val openPulls: Int = 0, | |
| 87 | + val events: List<Event> = emptyList(), | |
| 88 | + val commits: List<RepoCommit> = emptyList(), | |
| 89 | + val discover: List<RepoSummary> = emptyList(), | |
| 90 | + ) | |
| 91 | + | |
| 92 | + @Serializable data class ReposData(val repos: List<RepoSummary>) | |
| 93 | + @Serializable data class ProfileData(val user: User, val repos: List<RepoSummary>) | |
| 94 | + | |
| 95 | + @Serializable data class TreeEntry(val name: String, val path: String, val type: String, val size: Long? = null) { | |
| 96 | + val isDir: Boolean get() = type == "tree" | |
| 97 | + val isSubmodule: Boolean get() = type == "commit" | |
| 98 | + } | |
| 99 | + | |
| 100 | + @Serializable data class Readme(val path: String, val text: String) | |
| 101 | + @Serializable data class CloneUrls(val https: String, val ssh: String) | |
| 102 | + @Serializable data class RepoCounts(val issues: Int = 0, val pulls: Int = 0, val releases: Int = 0, val commits: Int = 0, val branches: Int = 0) | |
| 103 | + | |
| 104 | + @Serializable | |
| 105 | + data class RepoDetail( | |
| 106 | + val repo: RepoSummary, | |
| 107 | + val access: String, | |
| 108 | + val canWrite: Boolean, | |
| 109 | + val defaultBranch: String? = null, | |
| 110 | + val branches: List<String> = emptyList(), | |
| 111 | + val tags: List<String> = emptyList(), | |
| 112 | + val cloneUrls: CloneUrls, | |
| 113 | + val counts: RepoCounts = RepoCounts(), | |
| 114 | + val lastCommit: CommitInfo? = null, | |
| 115 | + val entries: List<TreeEntry> = emptyList(), | |
| 116 | + val readme: Readme? = null, | |
| 117 | + val wikiEnabled: Boolean = false, | |
| 118 | + ) | |
| 119 | + | |
| 120 | + @Serializable data class TreeData(val ref: String? = null, val path: String = "", val entries: List<TreeEntry> = emptyList()) | |
| 121 | + | |
| 122 | + @Serializable | |
| 123 | + data class BlobData( | |
| 124 | + val ref: String, | |
| 125 | + val path: String, | |
| 126 | + val size: Long, | |
| 127 | + val binary: Boolean, | |
| 128 | + val tooLarge: Boolean, | |
| 129 | + val image: Boolean = false, | |
| 130 | + val text: String? = null, | |
| 131 | + val lastCommit: CommitInfo? = null, | |
| 132 | + ) | |
| 133 | + | |
| 134 | + @Serializable data class CommitsData(val ref: String? = null, val commits: List<CommitInfo> = emptyList(), val hasMore: Boolean = false) | |
| 135 | + | |
| 136 | + @Serializable data class DiffLine(val kind: String, val text: String, val oldNo: Int? = null, val newNo: Int? = null) | |
| 137 | + | |
| 138 | + @Serializable | |
| 139 | + data class FileDiff( | |
| 140 | + val oldPath: String, | |
| 141 | + val newPath: String, | |
| 142 | + val status: String, | |
| 143 | + val binary: Boolean = false, | |
| 144 | + val additions: Int = 0, | |
| 145 | + val deletions: Int = 0, | |
| 146 | + val truncated: Boolean = false, | |
| 147 | + val lines: List<DiffLine> = emptyList(), | |
| 148 | + ) { | |
| 149 | + val path: String get() = if (status == "deleted") oldPath else newPath | |
| 150 | + } | |
| 151 | + | |
| 152 | + @Serializable data class CommitDetail(val commit: CommitInfo, val files: List<FileDiff>) | |
| 153 | + @Serializable data class FilesData(val files: List<FileDiff>) | |
| 154 | + | |
| 155 | + @Serializable data class BranchInfo(val name: String, val sha: String, val date: Long = 0, val subject: String = "", val isDefault: Boolean = false) | |
| 156 | + @Serializable data class BranchesData(val defaultBranch: String? = null, val branches: List<BranchInfo> = emptyList()) | |
| 157 | + | |
| 158 | + @Serializable data class EventsData(val events: List<Event>) | |
| 159 | + | |
| 160 | + // Issues | |
| 161 | + | |
| 162 | + @Serializable data class Label(val id: String, val name: String, val color: String) | |
| 163 | + | |
| 164 | + /** Someone an issue can be assigned to: a person, or an agent (then `agentOf` names its person). */ | |
| 165 | + @Serializable data class Member(val id: String, val username: String, val name: String = "", val agentOf: String? = null) | |
| 166 | + | |
| 167 | + @Serializable | |
| 168 | + data class Issue( | |
| 169 | + val id: String, | |
| 170 | + val number: Int, | |
| 171 | + val title: String, | |
| 172 | + val authorId: String = "", | |
| 173 | + val author: String = "", | |
| 174 | + val authorAgent: String? = null, | |
| 175 | + val status: String, | |
| 176 | + val priority: Int = 0, | |
| 177 | + val assigneeId: String? = null, | |
| 178 | + val assignee: String? = null, | |
| 179 | + val assigneeAgentOf: String? = null, | |
| 180 | + val estimate: Int? = null, | |
| 181 | + val dueDate: String? = null, | |
| 182 | + val sortOrder: Double = 0.0, | |
| 183 | + val labels: List<Label> = emptyList(), | |
| 184 | + val createdAt: Long = 0, | |
| 185 | + val updatedAt: Long = 0, | |
| 186 | + val commentCount: Int = 0, | |
| 187 | + val body: String? = null, | |
| 188 | + val state: String? = null, | |
| 189 | + ) | |
| 190 | + | |
| 191 | + @Serializable data class IssuesData(val canWrite: Boolean, val issues: List<Issue>, val labels: List<Label> = emptyList(), val members: List<Member> = emptyList()) | |
| 192 | + | |
| 193 | + @Serializable data class Comment(val id: String, val author: String, val agent: String? = null, val body: String, val createdAt: Long) | |
| 194 | + | |
| 195 | + @Serializable | |
| 196 | + data class IssueEvent( | |
| 197 | + val id: String, | |
| 198 | + val actor: String = "", | |
| 199 | + val agent: String? = null, | |
| 200 | + val field: String, | |
| 201 | + val oldValue: String? = null, | |
| 202 | + val newValue: String? = null, | |
| 203 | + val createdAt: Long, | |
| 204 | + ) | |
| 205 | + | |
| 206 | + @Serializable | |
| 207 | + data class IssueDetail( | |
| 208 | + val canWrite: Boolean, | |
| 209 | + val issue: Issue, | |
| 210 | + val comments: List<Comment> = emptyList(), | |
| 211 | + val events: List<IssueEvent> = emptyList(), | |
| 212 | + val labels: List<Label> = emptyList(), | |
| 213 | + val members: List<Member> = emptyList(), | |
| 214 | + ) | |
| 215 | + | |
| 216 | + @Serializable data class CreatedIssue(val number: Int) | |
| 217 | + | |
| 218 | + // Pull requests | |
| 219 | + | |
| 220 | + @Serializable | |
| 221 | + data class Pull( | |
| 222 | + val number: Int, | |
| 223 | + val title: String, | |
| 224 | + val body: String = "", | |
| 225 | + val author: String = "", | |
| 226 | + val agent: String? = null, | |
| 227 | + val head: String = "", | |
| 228 | + val headLabel: String = "", | |
| 229 | + val base: String = "", | |
| 230 | + val headSha: String = "", | |
| 231 | + val state: String, | |
| 232 | + val draft: Boolean = false, | |
| 233 | + val mergeSha: String? = null, | |
| 234 | + val mergeMethod: String? = null, | |
| 235 | + val mergedBy: String? = null, | |
| 236 | + val linkedIssues: List<Int> = emptyList(), | |
| 237 | + val commentCount: Int = 0, | |
| 238 | + val createdAt: Long = 0, | |
| 239 | + val updatedAt: Long = 0, | |
| 240 | + val closedAt: Long? = null, | |
| 241 | + val mergedAt: Long? = null, | |
| 242 | + ) | |
| 243 | + | |
| 244 | + @Serializable data class PullCounts(val open: Int = 0) | |
| 245 | + @Serializable data class PullsData(val canWrite: Boolean, val counts: PullCounts = PullCounts(), val pulls: List<Pull>) | |
| 246 | + | |
| 247 | + @Serializable data class Requirement(val key: String, val ok: Boolean, val label: String, val detail: String? = null) | |
| 248 | + | |
| 249 | + @Serializable | |
| 250 | + data class PullStatus( | |
| 251 | + val headSha: String, | |
| 252 | + val headExists: Boolean, | |
| 253 | + val ahead: Int = 0, | |
| 254 | + val behind: Int = 0, | |
| 255 | + val conflicts: List<String>? = null, | |
| 256 | + val checks: String = "none", | |
| 257 | + val runs: List<RunInfo> = emptyList(), | |
| 258 | + val approvals: List<String> = emptyList(), | |
| 259 | + val changesRequestedBy: List<String> = emptyList(), | |
| 260 | + val requirements: List<Requirement> = emptyList(), | |
| 261 | + val mergeable: Boolean = false, | |
| 262 | + ) | |
| 263 | + | |
| 264 | + @Serializable data class Review(val id: String, val author: String, val agent: String? = null, val state: String, val body: String = "", val commitSha: String = "", val createdAt: Long) | |
| 265 | + | |
| 266 | + @Serializable | |
| 267 | + data class ReviewThread( | |
| 268 | + val path: String? = null, | |
| 269 | + val line: Int? = null, | |
| 270 | + val side: String? = null, | |
| 271 | + val snippet: String? = null, | |
| 272 | + val resolved: Boolean = false, | |
| 273 | + val outdated: Boolean = false, | |
| 274 | + val comments: List<Comment> = emptyList(), | |
| 275 | + val createdAt: Long = 0, | |
| 276 | + ) | |
| 277 | + | |
| 278 | + @Serializable data class PullEvent(val id: String, val actor: String? = null, val agent: String? = null, val kind: String, val data: JsonObject = JsonObject(emptyMap()), val createdAt: Long) | |
| 279 | + | |
| 280 | + @Serializable | |
| 281 | + data class PullDetail( | |
| 282 | + val pull: Pull, | |
| 283 | + val canWrite: Boolean, | |
| 284 | + val canEdit: Boolean, | |
| 285 | + val canMerge: Boolean, | |
| 286 | + val status: PullStatus, | |
| 287 | + val reviews: List<Review> = emptyList(), | |
| 288 | + val comments: List<Comment> = emptyList(), | |
| 289 | + val threads: List<ReviewThread> = emptyList(), | |
| 290 | + val events: List<PullEvent> = emptyList(), | |
| 291 | + val commits: List<CommitInfo> = emptyList(), | |
| 292 | + ) | |
| 293 | + | |
| 294 | + // Actions and releases | |
| 295 | + | |
| 296 | + @Serializable | |
| 297 | + data class RunInfo( | |
| 298 | + val number: Int, | |
| 299 | + val workflowName: String, | |
| 300 | + val workflowPath: String = "", | |
| 301 | + val event: String = "", | |
| 302 | + val ref: String = "", | |
| 303 | + val sha: String = "", | |
| 304 | + val title: String = "", | |
| 305 | + val actor: String? = null, | |
| 306 | + val agent: String? = null, | |
| 307 | + val status: String, | |
| 308 | + val error: String? = null, | |
| 309 | + val createdAt: Long = 0, | |
| 310 | + val startedAt: Long? = null, | |
| 311 | + val finishedAt: Long? = null, | |
| 312 | + ) | |
| 313 | + | |
| 314 | + @Serializable data class RunsData(val runs: List<RunInfo>, val hasMore: Boolean = false) | |
| 315 | + | |
| 316 | + @Serializable | |
| 317 | + data class StepInfo( | |
| 318 | + val index: Int, | |
| 319 | + val name: String, | |
| 320 | + val status: String, | |
| 321 | + val exitCode: Int? = null, | |
| 322 | + val startedAt: Long? = null, | |
| 323 | + val finishedAt: Long? = null, | |
| 324 | + val log: String = "", | |
| 325 | + val logTruncated: Boolean = false, | |
| 326 | + ) | |
| 327 | + | |
| 328 | + @Serializable | |
| 329 | + data class JobInfo( | |
| 330 | + val key: String, | |
| 331 | + val name: String, | |
| 332 | + val status: String, | |
| 333 | + val runner: String? = null, | |
| 334 | + val startedAt: Long? = null, | |
| 335 | + val finishedAt: Long? = null, | |
| 336 | + val steps: List<StepInfo> = emptyList(), | |
| 337 | + ) | |
| 338 | + | |
| 339 | + @Serializable data class RunDetail(val run: RunInfo, val jobs: List<JobInfo>) | |
| 340 | + | |
| 341 | + @Serializable data class Asset(val name: String, val size: Long, val downloads: Int = 0, val url: String) | |
| 342 | + | |
| 343 | + @Serializable | |
| 344 | + data class Release( | |
| 345 | + val tag: String, | |
| 346 | + val title: String, | |
| 347 | + val body: String = "", | |
| 348 | + val draft: Boolean = false, | |
| 349 | + val prerelease: Boolean = false, | |
| 350 | + val author: String? = null, | |
| 351 | + val createdAt: Long = 0, | |
| 352 | + val publishedAt: Long? = null, | |
| 353 | + val assets: List<Asset> = emptyList(), | |
| 354 | + ) | |
| 355 | + | |
| 356 | + @Serializable data class ReleasesData(val releases: List<Release>) | |
| @@ -0,0 +1,49 @@ | ||
| 1 | + package cc.vectrum.app.data | |
| 2 | + | |
| 3 | + import okhttp3.HttpUrl | |
| 4 | + import okhttp3.OkHttpClient | |
| 5 | + import java.io.IOException | |
| 6 | + | |
| 7 | + /** The hosted Vectrum. */ | |
| 8 | + const val HOSTED_SERVER = "https://vectrum.cc" | |
| 9 | + | |
| 10 | + sealed interface Probe { | |
| 11 | + /** A Vectrum with the app's API, at this (normalized) address. */ | |
| 12 | + data class Found(val url: HttpUrl, val info: ServerInfo) : Probe | |
| 13 | + | |
| 14 | + /** Nothing usable there; the message says why, in words a person can act on. */ | |
| 15 | + data class Failed(val message: String) : Probe | |
| 16 | + } | |
| 17 | + | |
| 18 | + /** | |
| 19 | + * Finds the Vectrum server behind what someone typed. Without a scheme it tries HTTPS first and then | |
| 20 | + * plain HTTP (common for servers at home), and tells "no server", "not a Vectrum" and "a Vectrum too | |
| 21 | + * old for the app" apart. | |
| 22 | + */ | |
| 23 | + suspend fun probeServer(http: OkHttpClient, input: String): Probe { | |
| 24 | + val typed = input.trim() | |
| 25 | + if (typed.isEmpty()) return Probe.Failed("Enter your server's address.") | |
| 26 | + val candidates = if (typed.contains("://")) listOfNotNull(normalizeServer(typed)) else listOfNotNull(normalizeServer("https://$typed"), normalizeServer("http://$typed")) | |
| 27 | + if (candidates.isEmpty()) return Probe.Failed("That doesn't look like a server address. Try something like git.example.com or 192.168.1.10:3000.") | |
| 28 | + var outcome: Probe? = null | |
| 29 | + for (url in candidates) { | |
| 30 | + val api = Api(http, url, null) | |
| 31 | + try { | |
| 32 | + return Probe.Found(url, api.serverInfo()) | |
| 33 | + } catch (e: ApiException) { | |
| 34 | + // It answers, but not with the app's API: an older Vectrum, or something else entirely. | |
| 35 | + if (outcome == null) { | |
| 36 | + outcome = if (api.healthy()) { | |
| 37 | + Probe.Failed("${url.host} runs Vectrum, but a version without the app's API. Once it's updated, you can connect.") | |
| 38 | + } else { | |
| 39 | + Probe.Failed("There's no Vectrum server at ${url.host}.") | |
| 40 | + } | |
| 41 | + } | |
| 42 | + } catch (e: IOException) { | |
| 43 | + // Unreachable at this scheme: try the next one. | |
| 44 | + } catch (e: kotlinx.serialization.SerializationException) { | |
| 45 | + if (outcome == null) outcome = Probe.Failed("There's no Vectrum server at ${url.host}.") | |
| 46 | + } | |
| 47 | + } | |
| 48 | + return outcome ?: Probe.Failed("Can't reach ${candidates.first().host}. Check the address and your connection.") | |
| 49 | + } | |
| @@ -0,0 +1,71 @@ | ||
| 1 | + package cc.vectrum.app.data | |
| 2 | + | |
| 3 | + import android.content.Context | |
| 4 | + import androidx.datastore.preferences.core.Preferences | |
| 5 | + import androidx.datastore.preferences.core.booleanPreferencesKey | |
| 6 | + import androidx.datastore.preferences.core.edit | |
| 7 | + import androidx.datastore.preferences.core.stringPreferencesKey | |
| 8 | + import androidx.datastore.preferences.preferencesDataStore | |
| 9 | + import cc.vectrum.app.ui.theme.ThemeMode | |
| 10 | + import kotlinx.coroutines.flow.Flow | |
| 11 | + import kotlinx.coroutines.flow.map | |
| 12 | + | |
| 13 | + /** A signed-in account: which server, its session token, and who it is. */ | |
| 14 | + data class Session(val server: String, val token: String, val user: User) | |
| 15 | + | |
| 16 | + /** `onboarded`: the setup (welcome, server, account) was finished once; later sign-ins go straight to the account step. */ | |
| 17 | + data class Settings(val session: Session?, val lastServer: String, val theme: ThemeMode, val onboarded: Boolean) | |
| 18 | + | |
| 19 | + private val Context.store by preferencesDataStore(name = "vectrum") | |
| 20 | + | |
| 21 | + /** | |
| 22 | + * Where the app keeps the session and its preferences, in the app's private storage. The token is | |
| 23 | + * the same 30-day session token the website keeps in its cookie; signing out forgets it. | |
| 24 | + */ | |
| 25 | + class SessionStore(private val context: Context) { | |
| 26 | + private object Keys { | |
| 27 | + val server = stringPreferencesKey("server") | |
| 28 | + val token = stringPreferencesKey("token") | |
| 29 | + val user = stringPreferencesKey("user") | |
| 30 | + val lastServer = stringPreferencesKey("last_server") | |
| 31 | + val theme = stringPreferencesKey("theme") | |
| 32 | + val onboarded = booleanPreferencesKey("onboarded") | |
| 33 | + } | |
| 34 | + | |
| 35 | + val settings: Flow<Settings> = context.store.data.map { p -> read(p) } | |
| 36 | + | |
| 37 | + private fun read(p: Preferences): Settings { | |
| 38 | + val server = p[Keys.server] | |
| 39 | + val token = p[Keys.token] | |
| 40 | + val user = p[Keys.user]?.let { runCatching { json.decodeFromString<User>(it) }.getOrNull() } | |
| 41 | + val session = if (server != null && token != null && user != null) Session(server, token, user) else null | |
| 42 | + val theme = p[Keys.theme]?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } } ?: ThemeMode.System | |
| 43 | + return Settings(session, p[Keys.lastServer] ?: "", theme, p[Keys.onboarded] ?: false) | |
| 44 | + } | |
| 45 | + | |
| 46 | + suspend fun signIn(session: Session) { | |
| 47 | + context.store.edit { | |
| 48 | + it[Keys.server] = session.server | |
| 49 | + it[Keys.token] = session.token | |
| 50 | + it[Keys.user] = json.encodeToString(User.serializer(), session.user) | |
| 51 | + it[Keys.lastServer] = session.server | |
| 52 | + it[Keys.onboarded] = true | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + suspend fun updateUser(user: User) { | |
| 57 | + context.store.edit { it[Keys.user] = json.encodeToString(User.serializer(), user) } | |
| 58 | + } | |
| 59 | + | |
| 60 | + suspend fun signOut() { | |
| 61 | + context.store.edit { | |
| 62 | + it.remove(Keys.token) | |
| 63 | + it.remove(Keys.user) | |
| 64 | + it.remove(Keys.server) | |
| 65 | + } | |
| 66 | + } | |
| 67 | + | |
| 68 | + suspend fun setTheme(theme: ThemeMode) { | |
| 69 | + context.store.edit { it[Keys.theme] = theme.name } | |
| 70 | + } | |
| 71 | + } | |
| @@ -0,0 +1,320 @@ | ||
| 1 | + package cc.vectrum.app.ui | |
| 2 | + | |
| 3 | + import androidx.compose.animation.AnimatedVisibility | |
| 4 | + import androidx.compose.animation.EnterTransition | |
| 5 | + import androidx.compose.animation.ExitTransition | |
| 6 | + import androidx.compose.animation.core.tween | |
| 7 | + import androidx.compose.animation.fadeIn | |
| 8 | + import androidx.compose.animation.fadeOut | |
| 9 | + import androidx.compose.animation.slideInHorizontally | |
| 10 | + import androidx.compose.animation.slideInVertically | |
| 11 | + import androidx.compose.animation.slideOutHorizontally | |
| 12 | + import androidx.compose.animation.slideOutVertically | |
| 13 | + import androidx.compose.foundation.background | |
| 14 | + import androidx.compose.foundation.clickable | |
| 15 | + import androidx.compose.foundation.interaction.MutableInteractionSource | |
| 16 | + import androidx.compose.foundation.layout.Arrangement | |
| 17 | + import androidx.compose.foundation.layout.Box | |
| 18 | + import androidx.compose.foundation.layout.Row | |
| 19 | + import androidx.compose.foundation.layout.Spacer | |
| 20 | + import androidx.compose.foundation.layout.WindowInsets | |
| 21 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 22 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 23 | + import androidx.compose.foundation.layout.statusBars | |
| 24 | + import androidx.compose.foundation.layout.windowInsetsTopHeight | |
| 25 | + import androidx.compose.foundation.layout.height | |
| 26 | + import androidx.compose.foundation.layout.navigationBars | |
| 27 | + import androidx.compose.foundation.layout.padding | |
| 28 | + import androidx.compose.foundation.layout.size | |
| 29 | + import androidx.compose.foundation.layout.width | |
| 30 | + import androidx.compose.foundation.layout.windowInsetsPadding | |
| 31 | + import androidx.compose.material3.Icon | |
| 32 | + import androidx.compose.material3.Text | |
| 33 | + import androidx.compose.runtime.Composable | |
| 34 | + import androidx.compose.runtime.CompositionLocalProvider | |
| 35 | + import androidx.compose.runtime.LaunchedEffect | |
| 36 | + import androidx.compose.runtime.getValue | |
| 37 | + import androidx.compose.runtime.remember | |
| 38 | + import androidx.compose.runtime.staticCompositionLocalOf | |
| 39 | + import androidx.compose.ui.Alignment | |
| 40 | + import androidx.compose.ui.Modifier | |
| 41 | + import androidx.compose.ui.graphics.Brush | |
| 42 | + import androidx.compose.ui.graphics.vector.ImageVector | |
| 43 | + import androidx.compose.ui.hapticfeedback.HapticFeedbackType | |
| 44 | + import androidx.compose.ui.platform.LocalHapticFeedback | |
| 45 | + import androidx.compose.ui.platform.LocalUriHandler | |
| 46 | + import androidx.compose.ui.unit.dp | |
| 47 | + import androidx.navigation.NavDestination | |
| 48 | + import androidx.navigation.NavDestination.Companion.hasRoute | |
| 49 | + import androidx.navigation.NavGraph.Companion.findStartDestination | |
| 50 | + import androidx.navigation.NavHostController | |
| 51 | + import androidx.navigation.compose.NavHost | |
| 52 | + import androidx.navigation.compose.composable | |
| 53 | + import androidx.navigation.compose.currentBackStackEntryAsState | |
| 54 | + import androidx.navigation.compose.rememberNavController | |
| 55 | + import androidx.navigation.toRoute | |
| 56 | + import cc.vectrum.app.data.Api | |
| 57 | + import cc.vectrum.app.data.Session | |
| 58 | + import cc.vectrum.app.ui.components.LocalToaster | |
| 59 | + import cc.vectrum.app.ui.components.ToastHost | |
| 60 | + import cc.vectrum.app.ui.components.Toaster | |
| 61 | + import cc.vectrum.app.ui.icons.VIcons | |
| 62 | + import cc.vectrum.app.ui.screens.CommitScreen | |
| 63 | + import cc.vectrum.app.ui.screens.ExploreScreen | |
| 64 | + import cc.vectrum.app.ui.screens.FileScreen | |
| 65 | + import cc.vectrum.app.ui.screens.HomeScreen | |
| 66 | + import cc.vectrum.app.ui.screens.IssueScreen | |
| 67 | + import cc.vectrum.app.ui.screens.MeScreen | |
| 68 | + import cc.vectrum.app.ui.screens.NewIssueScreen | |
| 69 | + import cc.vectrum.app.ui.screens.ProfileScreen | |
| 70 | + import cc.vectrum.app.ui.screens.PullScreen | |
| 71 | + import cc.vectrum.app.ui.screens.RepoScreen | |
| 72 | + import cc.vectrum.app.ui.screens.RunScreen | |
| 73 | + import cc.vectrum.app.ui.screens.TreeScreen | |
| 74 | + import cc.vectrum.app.ui.theme.Radii | |
| 75 | + import cc.vectrum.app.ui.theme.ThemeMode | |
| 76 | + import cc.vectrum.app.ui.theme.Vectrum | |
| 77 | + import cc.vectrum.app.ui.theme.plate | |
| 78 | + import cc.vectrum.app.ui.theme.raised | |
| 79 | + import kotlinx.serialization.Serializable | |
| 80 | + | |
| 81 | + // Routes | |
| 82 | + | |
| 83 | + @Serializable object HomeRoute | |
| 84 | + @Serializable object ExploreRoute | |
| 85 | + @Serializable object MeRoute | |
| 86 | + @Serializable data class ProfileRoute(val username: String) | |
| 87 | + @Serializable data class RepoRoute(val owner: String, val name: String, val tab: String = "code") | |
| 88 | + @Serializable data class TreeRoute(val owner: String, val name: String, val ref: String, val path: String) | |
| 89 | + @Serializable data class FileRoute(val owner: String, val name: String, val ref: String, val path: String) | |
| 90 | + @Serializable data class CommitRoute(val owner: String, val name: String, val sha: String) | |
| 91 | + @Serializable data class IssueRoute(val owner: String, val name: String, val number: Int) | |
| 92 | + @Serializable data class NewIssueRoute(val owner: String, val name: String) | |
| 93 | + @Serializable data class PullRoute(val owner: String, val name: String, val number: Int) | |
| 94 | + @Serializable data class RunRoute(val owner: String, val name: String, val number: Int) | |
| 95 | + | |
| 96 | + val LocalApi = staticCompositionLocalOf<Api?> { null } | |
| 97 | + val LocalSession = staticCompositionLocalOf<Session?> { null } | |
| 98 | + val LocalNav = staticCompositionLocalOf<Nav> { error("No navigator") } | |
| 99 | + | |
| 100 | + /** Everything the app can open, by what it is rather than by route. */ | |
| 101 | + class Nav(val controller: NavHostController, private val openUrl: (String) -> Unit, private val origin: String) { | |
| 102 | + fun back() { | |
| 103 | + controller.popBackStack() | |
| 104 | + } | |
| 105 | + | |
| 106 | + fun repo(owner: String, name: String, tab: String = "code") = controller.navigate(RepoRoute(owner, name, tab)) | |
| 107 | + fun tree(owner: String, name: String, ref: String, path: String) = controller.navigate(TreeRoute(owner, name, ref, path)) | |
| 108 | + fun file(owner: String, name: String, ref: String, path: String) = controller.navigate(FileRoute(owner, name, ref, path)) | |
| 109 | + fun commit(owner: String, name: String, sha: String) = controller.navigate(CommitRoute(owner, name, sha)) | |
| 110 | + fun issue(owner: String, name: String, number: Int) = controller.navigate(IssueRoute(owner, name, number)) | |
| 111 | + fun newIssue(owner: String, name: String) = controller.navigate(NewIssueRoute(owner, name)) | |
| 112 | + fun pull(owner: String, name: String, number: Int) = controller.navigate(PullRoute(owner, name, number)) | |
| 113 | + fun run(owner: String, name: String, number: Int) = controller.navigate(RunRoute(owner, name, number)) | |
| 114 | + fun profile(username: String) = controller.navigate(ProfileRoute(username)) | |
| 115 | + | |
| 116 | + /** Switches to a top-level tab (Home, Explore, You), keeping each tab's state. */ | |
| 117 | + fun tab(route: Any) = controller.navigate(route) { | |
| 118 | + popUpTo(controller.graph.findStartDestination().id) { saveState = true } | |
| 119 | + launchSingleTop = true | |
| 120 | + restoreState = true | |
| 121 | + } | |
| 122 | + | |
| 123 | + /** "#12" in a repository: an issue or a pull request (they share numbers); the issue screen hands over to the pull request if it is one. */ | |
| 124 | + fun number(owner: String, name: String, number: Int) = issue(owner, name, number) | |
| 125 | + | |
| 126 | + /** A link in Markdown: pages of this server open in the app, everything else in the browser. */ | |
| 127 | + fun link(url: String, owner: String? = null, name: String? = null, ref: String? = null, baseDir: String = "") { | |
| 128 | + val scheme = Regex("^([a-z][a-z0-9+.-]*):", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.lowercase() | |
| 129 | + val local = when { | |
| 130 | + url == origin || url.startsWith("$origin/") -> url.removePrefix(origin).ifEmpty { "/" } | |
| 131 | + url.startsWith("//") -> return openUrl("https:$url") | |
| 132 | + url.startsWith("/") -> url | |
| 133 | + url.startsWith("#") -> return | |
| 134 | + scheme != null -> { | |
| 135 | + // Links in documents are written by anyone: only the web and mail leave the app. | |
| 136 | + if (scheme == "http" || scheme == "https" || scheme == "mailto") openUrl(url) | |
| 137 | + return | |
| 138 | + } | |
| 139 | + owner != null && name != null -> { | |
| 140 | + // A path in the repository, relative to the document (in issues and comments: the default branch). | |
| 141 | + val path = normalize(if (baseDir.isEmpty()) url else "$baseDir/$url").substringBefore('#').substringBefore('?') | |
| 142 | + if (path.isEmpty()) repo(owner, name) else file(owner, name, ref ?: "HEAD", path) | |
| 143 | + return | |
| 144 | + } | |
| 145 | + else -> null | |
| 146 | + } | |
| 147 | + if (local != null && !openLocal(local)) openUrl(origin + local) | |
| 148 | + } | |
| 149 | + | |
| 150 | + private fun openLocal(path: String): Boolean { | |
| 151 | + val parts = path.substringBefore('?').substringBefore('#').trim('/').split('/').filter { it.isNotEmpty() } | |
| 152 | + if (parts.isEmpty()) return false | |
| 153 | + if (parts.size == 1) { | |
| 154 | + profile(parts[0]); return true | |
| 155 | + } | |
| 156 | + val (owner, name) = parts[0] to parts[1].removeSuffix(".git") | |
| 157 | + val rest = parts.drop(2) | |
| 158 | + when (rest.firstOrNull()) { | |
| 159 | + null -> repo(owner, name) | |
| 160 | + "issues" -> rest.getOrNull(1)?.toIntOrNull()?.let { issue(owner, name, it) } ?: repo(owner, name, "issues") | |
| 161 | + "pulls" -> rest.getOrNull(1)?.toIntOrNull()?.let { pull(owner, name, it) } ?: repo(owner, name, "pulls") | |
| 162 | + "commit" -> rest.getOrNull(1)?.let { commit(owner, name, it) } ?: return false | |
| 163 | + "commits" -> repo(owner, name, "commits") | |
| 164 | + "actions" -> if (rest.getOrNull(1) == "runs") rest.getOrNull(2)?.toIntOrNull()?.let { run(owner, name, it) } ?: return false else repo(owner, name, "actions") | |
| 165 | + "releases" -> repo(owner, name, "releases") | |
| 166 | + "blob" -> if (rest.size >= 3) file(owner, name, rest[1], rest.drop(2).joinToString("/")) else return false | |
| 167 | + "tree" -> if (rest.size >= 3) tree(owner, name, rest[1], rest.drop(2).joinToString("/")) else repo(owner, name) | |
| 168 | + else -> return false | |
| 169 | + } | |
| 170 | + return true | |
| 171 | + } | |
| 172 | + | |
| 173 | + private fun normalize(path: String): String { | |
| 174 | + val out = ArrayDeque<String>() | |
| 175 | + for (p in path.split('/')) when (p) { | |
| 176 | + "", "." -> {} | |
| 177 | + ".." -> out.removeLastOrNull() | |
| 178 | + else -> out.addLast(p) | |
| 179 | + } | |
| 180 | + return out.joinToString("/") | |
| 181 | + } | |
| 182 | + } | |
| 183 | + | |
| 184 | + private val enter: EnterTransition = slideInHorizontally(tween(320)) { it / 3 } + fadeIn(tween(240)) | |
| 185 | + private val exit: ExitTransition = slideOutHorizontally(tween(320)) { -it / 10 } + fadeOut(tween(200)) | |
| 186 | + private val popEnter: EnterTransition = slideInHorizontally(tween(320)) { -it / 10 } + fadeIn(tween(240)) | |
| 187 | + private val popExit: ExitTransition = slideOutHorizontally(tween(320)) { it / 3 } + fadeOut(tween(200)) | |
| 188 | + | |
| 189 | + /** The signed-in app: the screens, the floating tab bar, and toasts. */ | |
| 190 | + @Composable | |
| 191 | + fun MainShell(session: Session, api: Api, theme: ThemeMode, onTheme: (ThemeMode) -> Unit, onSignOut: () -> Unit) { | |
| 192 | + val controller = rememberNavController() | |
| 193 | + val uri = LocalUriHandler.current | |
| 194 | + val nav = remember(controller, api) { Nav(controller, { runCatching { uri.openUri(it) } }, api.origin) } | |
| 195 | + val toaster = remember { Toaster() } | |
| 196 | + val entry by controller.currentBackStackEntryAsState() | |
| 197 | + val destination = entry?.destination | |
| 198 | + val topLevel = destination == null || destination.hasRoute<HomeRoute>() || destination.hasRoute<ExploreRoute>() || destination.hasRoute<MeRoute>() | |
| 199 | + | |
| 200 | + LaunchedEffect(Unit) { | |
| 201 | + AppEvents.sessionExpired.collect { | |
| 202 | + toaster.error("Your session has ended. Sign in again.") | |
| 203 | + onSignOut() | |
| 204 | + } | |
| 205 | + } | |
| 206 | + | |
| 207 | + CompositionLocalProvider(LocalApi provides api, LocalSession provides session, LocalNav provides nav, LocalToaster provides toaster) { | |
| 208 | + Box(Modifier.fillMaxSize().background(Vectrum.colors.page)) { | |
| 209 | + NavHost( | |
| 210 | + controller, | |
| 211 | + startDestination = HomeRoute, | |
| 212 | + enterTransition = { enter }, | |
| 213 | + exitTransition = { exit }, | |
| 214 | + popEnterTransition = { popEnter }, | |
| 215 | + popExitTransition = { popExit }, | |
| 216 | + ) { | |
| 217 | + composable<HomeRoute>(enterTransition = { fadeIn(tween(220)) }, exitTransition = { exitFor(targetState.destination) }) { HomeScreen() } | |
| 218 | + composable<ExploreRoute>(enterTransition = { fadeIn(tween(220)) }, exitTransition = { exitFor(targetState.destination) }) { ExploreScreen() } | |
| 219 | + composable<MeRoute>(enterTransition = { fadeIn(tween(220)) }, exitTransition = { exitFor(targetState.destination) }) { | |
| 220 | + MeScreen(theme = theme, onTheme = onTheme, onSignOut = onSignOut) | |
| 221 | + } | |
| 222 | + composable<ProfileRoute> { ProfileScreen(it.toRoute<ProfileRoute>().username) } | |
| 223 | + composable<RepoRoute> { val r = it.toRoute<RepoRoute>(); RepoScreen(r.owner, r.name, r.tab) } | |
| 224 | + composable<TreeRoute> { val r = it.toRoute<TreeRoute>(); TreeScreen(r.owner, r.name, r.ref, r.path) } | |
| 225 | + composable<FileRoute> { val r = it.toRoute<FileRoute>(); FileScreen(r.owner, r.name, r.ref, r.path) } | |
| 226 | + composable<CommitRoute> { val r = it.toRoute<CommitRoute>(); CommitScreen(r.owner, r.name, r.sha) } | |
| 227 | + composable<IssueRoute> { val r = it.toRoute<IssueRoute>(); IssueScreen(r.owner, r.name, r.number) } | |
| 228 | + composable<NewIssueRoute>(enterTransition = { slideInVertically(tween(320)) { it / 4 } + fadeIn() }, popExitTransition = { slideOutVertically(tween(260)) { it / 4 } + fadeOut() }) { | |
| 229 | + val r = it.toRoute<NewIssueRoute>(); NewIssueScreen(r.owner, r.name) | |
| 230 | + } | |
| 231 | + composable<PullRoute> { val r = it.toRoute<PullRoute>(); PullScreen(r.owner, r.name, r.number) } | |
| 232 | + composable<RunRoute> { val r = it.toRoute<RunRoute>(); RunScreen(r.owner, r.name, r.number) } | |
| 233 | + } | |
| 234 | + | |
| 235 | + if (topLevel) { | |
| 236 | + // The page fades in under the status bar and behind the tab bar, so content scrolls away beneath them. | |
| 237 | + val page = Vectrum.colors.page | |
| 238 | + Box(Modifier.align(Alignment.TopCenter).fillMaxWidth().windowInsetsTopHeight(WindowInsets.statusBars).background(page.copy(alpha = 0.96f))) | |
| 239 | + Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(120.dp).background(Brush.verticalGradient(listOf(page.copy(alpha = 0f), page.copy(alpha = 0.92f))))) | |
| 240 | + } | |
| 241 | + AnimatedVisibility( | |
| 242 | + visible = topLevel, | |
| 243 | + modifier = Modifier.align(Alignment.BottomCenter), | |
| 244 | + enter = slideInVertically { it } + fadeIn(), | |
| 245 | + exit = slideOutVertically { it } + fadeOut(), | |
| 246 | + ) { | |
| 247 | + TabBar( | |
| 248 | + current = when { | |
| 249 | + destination?.hasRoute<ExploreRoute>() == true -> 1 | |
| 250 | + destination?.hasRoute<MeRoute>() == true -> 2 | |
| 251 | + else -> 0 | |
| 252 | + }, | |
| 253 | + onSelect = { index -> | |
| 254 | + val route: Any = when (index) { | |
| 255 | + 1 -> ExploreRoute | |
| 256 | + 2 -> MeRoute | |
| 257 | + else -> HomeRoute | |
| 258 | + } | |
| 259 | + nav.tab(route) | |
| 260 | + }, | |
| 261 | + ) | |
| 262 | + } | |
| 263 | + ToastHost(toaster) | |
| 264 | + } | |
| 265 | + } | |
| 266 | + } | |
| 267 | + | |
| 268 | + private fun exitFor(target: NavDestination): ExitTransition = | |
| 269 | + if (target.hasRoute<HomeRoute>() || target.hasRoute<ExploreRoute>() || target.hasRoute<MeRoute>()) fadeOut(tween(160)) else exit | |
| 270 | + | |
| 271 | + private data class Tab(val label: String, val icon: ImageVector) | |
| 272 | + | |
| 273 | + /** The floating tab bar: a raised plate with the current tab's key standing out of it. */ | |
| 274 | + @Composable | |
| 275 | + private fun TabBar(current: Int, onSelect: (Int) -> Unit) { | |
| 276 | + val c = Vectrum.colors | |
| 277 | + val haptics = LocalHapticFeedback.current | |
| 278 | + val tabs = listOf(Tab("Home", VIcons.Home), Tab("Explore", VIcons.Compass), Tab("You", VIcons.Person)) | |
| 279 | + Box(Modifier.windowInsetsPadding(WindowInsets.navigationBars).padding(bottom = 14.dp)) { | |
| 280 | + Row( | |
| 281 | + Modifier.plate(Radii.pill).padding(5.dp), | |
| 282 | + horizontalArrangement = Arrangement.spacedBy(2.dp), | |
| 283 | + verticalAlignment = Alignment.CenterVertically, | |
| 284 | + ) { | |
| 285 | + tabs.forEachIndexed { i, tab -> | |
| 286 | + val active = i == current | |
| 287 | + Row( | |
| 288 | + Modifier | |
| 289 | + .height(48.dp) | |
| 290 | + .then(if (active) Modifier.raised(Radii.pill) else Modifier) | |
| 291 | + .clickable(remember { MutableInteractionSource() }, indication = null) { | |
| 292 | + if (!active) haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) | |
| 293 | + onSelect(i) | |
| 294 | + } | |
| 295 | + .padding(horizontal = if (active) 18.dp else 16.dp), | |
| 296 | + verticalAlignment = Alignment.CenterVertically, | |
| 297 | + ) { | |
| 298 | + Icon(tab.icon, tab.label, Modifier.size(21.dp), tint = if (active) c.ink else c.ink3) | |
| 299 | + AnimatedVisibility(active) { | |
| 300 | + Row { | |
| 301 | + Spacer(Modifier.width(8.dp)) | |
| 302 | + Text(tab.label, style = Vectrum.type.ui, color = c.ink) | |
| 303 | + } | |
| 304 | + } | |
| 305 | + } | |
| 306 | + } | |
| 307 | + } | |
| 308 | + } | |
| 309 | + } | |
| 310 | + | |
| 311 | + @Composable | |
| 312 | + fun SignedOutShell(content: @Composable () -> Unit) { | |
| 313 | + val toaster = remember { Toaster() } | |
| 314 | + CompositionLocalProvider(LocalToaster provides toaster) { | |
| 315 | + Box(Modifier.fillMaxSize().background(Vectrum.colors.page)) { | |
| 316 | + content() | |
| 317 | + ToastHost(toaster) | |
| 318 | + } | |
| 319 | + } | |
| 320 | + } | |
| @@ -0,0 +1,90 @@ | ||
| 1 | + package cc.vectrum.app.ui | |
| 2 | + | |
| 3 | + import java.text.SimpleDateFormat | |
| 4 | + import java.util.Calendar | |
| 5 | + import java.util.Date | |
| 6 | + import java.util.Locale | |
| 7 | + import kotlin.math.abs | |
| 8 | + import kotlin.math.roundToLong | |
| 9 | + | |
| 10 | + // The website's formats (lib/format.ts): "3 days ago", "Oct 3, 2026", "1.2 MB", "3m 12s". | |
| 11 | + | |
| 12 | + private val units = listOf( | |
| 13 | + "year" to 365L * 24 * 3600_000, | |
| 14 | + "month" to 30L * 24 * 3600_000, | |
| 15 | + "week" to 7L * 24 * 3600_000, | |
| 16 | + "day" to 24L * 3600_000, | |
| 17 | + "hour" to 3600_000L, | |
| 18 | + "minute" to 60_000L, | |
| 19 | + ) | |
| 20 | + | |
| 21 | + fun timeAgo(ms: Long, now: Long = System.currentTimeMillis()): String { | |
| 22 | + val diff = ms - now | |
| 23 | + for ((unit, size) in units) { | |
| 24 | + if (abs(diff) >= size) { | |
| 25 | + val n = (diff.toDouble() / size).roundToLong() | |
| 26 | + // Intl.RelativeTimeFormat's "auto" wording, as on the website: "yesterday", "last week", "1 hour ago". | |
| 27 | + val named = unit == "day" || unit == "week" || unit == "month" || unit == "year" | |
| 28 | + return when { | |
| 29 | + n == -1L && named -> if (unit == "day") "yesterday" else "last $unit" | |
| 30 | + n == 1L && named -> if (unit == "day") "tomorrow" else "next $unit" | |
| 31 | + n < 0 -> "${-n} $unit${if (n == -1L) "" else "s"} ago" | |
| 32 | + else -> "in $n $unit${if (n == 1L) "" else "s"}" | |
| 33 | + } | |
| 34 | + } | |
| 35 | + } | |
| 36 | + return "just now" | |
| 37 | + } | |
| 38 | + | |
| 39 | + /** "2h", "3d", "5w": for tight rows. */ | |
| 40 | + fun shortAgo(ms: Long, now: Long = System.currentTimeMillis()): String { | |
| 41 | + val s = (now - ms).coerceAtLeast(0) / 1000 | |
| 42 | + return when { | |
| 43 | + s < 60 -> "now" | |
| 44 | + s < 3600 -> "${s / 60}m" | |
| 45 | + s < 86400 -> "${s / 3600}h" | |
| 46 | + s < 7 * 86400 -> "${s / 86400}d" | |
| 47 | + s < 365 * 86400 -> "${s / (7 * 86400)}w" | |
| 48 | + else -> "${s / (365 * 86400)}y" | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + fun formatDate(ms: Long): String = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).format(Date(ms)) | |
| 53 | + | |
| 54 | + fun formatBytes(n: Long): String = when { | |
| 55 | + n < 1024 -> "$n Bytes" | |
| 56 | + n < 1024 * 1024 -> String.format(Locale.ENGLISH, "%.1f KB", n / 1024.0) | |
| 57 | + n < 1024L * 1024 * 1024 -> String.format(Locale.ENGLISH, "%.1f MB", n / 1024.0 / 1024.0) | |
| 58 | + else -> String.format(Locale.ENGLISH, "%.2f GB", n / 1024.0 / 1024.0 / 1024.0) | |
| 59 | + } | |
| 60 | + | |
| 61 | + fun formatDuration(ms: Long): String { | |
| 62 | + val s = (ms / 1000.0).roundToLong().coerceAtLeast(0) | |
| 63 | + if (s < 60) return "${s}s" | |
| 64 | + val m = s / 60 | |
| 65 | + if (m < 60) return "${m}m ${s % 60}s" | |
| 66 | + return "${m / 60}h ${m % 60}m" | |
| 67 | + } | |
| 68 | + | |
| 69 | + fun shortSha(sha: String) = sha.take(7) | |
| 70 | + | |
| 71 | + fun plural(n: Int, word: String, words: String = "${word}s") = "$n ${if (n == 1) word else words}" | |
| 72 | + | |
| 73 | + /** "Good morning", like the website's dashboard. */ | |
| 74 | + fun greeting(): String { | |
| 75 | + val h = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) | |
| 76 | + return when { | |
| 77 | + h < 5 -> "Good evening" | |
| 78 | + h < 12 -> "Good morning" | |
| 79 | + h < 18 -> "Good afternoon" | |
| 80 | + else -> "Good evening" | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + /** "Oct 3" for a YYYY-MM-DD date, without time zone conversions. */ | |
| 85 | + fun formatDay(date: String): String { | |
| 86 | + val parts = date.split("-").mapNotNull { it.toIntOrNull() } | |
| 87 | + if (parts.size != 3) return date | |
| 88 | + val months = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") | |
| 89 | + return "${months.getOrElse(parts[1] - 1) { "?" }} ${parts[2]}" | |
| 90 | + } | |
| @@ -0,0 +1,175 @@ | ||
| 1 | + package cc.vectrum.app.ui | |
| 2 | + | |
| 3 | + import androidx.compose.runtime.Composable | |
| 4 | + import androidx.compose.runtime.getValue | |
| 5 | + import androidx.compose.runtime.remember | |
| 6 | + import androidx.compose.runtime.rememberUpdatedState | |
| 7 | + import androidx.lifecycle.compose.LifecycleResumeEffect | |
| 8 | + import androidx.compose.runtime.mutableStateListOf | |
| 9 | + import androidx.compose.runtime.mutableStateOf | |
| 10 | + import androidx.compose.runtime.setValue | |
| 11 | + import androidx.lifecycle.ViewModel | |
| 12 | + import androidx.lifecycle.viewModelScope | |
| 13 | + import androidx.lifecycle.viewmodel.compose.viewModel | |
| 14 | + import cc.vectrum.app.data.ApiException | |
| 15 | + import kotlinx.coroutines.CancellationException | |
| 16 | + import kotlinx.coroutines.Job | |
| 17 | + import kotlinx.coroutines.flow.MutableSharedFlow | |
| 18 | + import kotlinx.coroutines.flow.asSharedFlow | |
| 19 | + import kotlinx.coroutines.launch | |
| 20 | + import kotlinx.serialization.SerializationException | |
| 21 | + import java.io.IOException | |
| 22 | + | |
| 23 | + sealed interface Load<out T> { | |
| 24 | + data object Loading : Load<Nothing> | |
| 25 | + data class Ready<T>(val data: T) : Load<T> | |
| 26 | + data class Failed(val message: String) : Load<Nothing> | |
| 27 | + } | |
| 28 | + | |
| 29 | + /** Things anywhere in the app tell the shell about: the session ran out. */ | |
| 30 | + object AppEvents { | |
| 31 | + private val expired = MutableSharedFlow<Unit>(extraBufferCapacity = 1) | |
| 32 | + val sessionExpired = expired.asSharedFlow() | |
| 33 | + fun sessionExpired() { | |
| 34 | + expired.tryEmit(Unit) | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + /** A failure in words for a person; a 401 also signs the app out. */ | |
| 39 | + fun describe(e: Throwable): String = when (e) { | |
| 40 | + is ApiException -> { | |
| 41 | + if (e.isUnauthorized) AppEvents.sessionExpired() | |
| 42 | + e.message ?: "Something went wrong." | |
| 43 | + } | |
| 44 | + is IOException -> "Can't reach the server. Check your connection and try again." | |
| 45 | + is SerializationException -> "The server sent something this app doesn't understand. Is it up to date?" | |
| 46 | + else -> e.message ?: "Something went wrong." | |
| 47 | + } | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * Loads a screen's data once and keeps it across recompositions and rotation (it lives as long as its | |
| 51 | + * screen's back stack entry). Pull to refresh reloads it in place; actions replace it with what the | |
| 52 | + * server answered. | |
| 53 | + */ | |
| 54 | + class Loader<T>(private val fetch: suspend () -> T) : ViewModel() { | |
| 55 | + var state: Load<T> by mutableStateOf(Load.Loading) | |
| 56 | + private set | |
| 57 | + var refreshing by mutableStateOf(false) | |
| 58 | + private set | |
| 59 | + private var job: Job? = null | |
| 60 | + | |
| 61 | + init { | |
| 62 | + load() | |
| 63 | + } | |
| 64 | + | |
| 65 | + /** Loads again: in place (refresh) when there's data on screen, else from the loading state. */ | |
| 66 | + fun load(refresh: Boolean = state is Load.Ready) { | |
| 67 | + job?.cancel() | |
| 68 | + if (refresh) refreshing = true else state = Load.Loading | |
| 69 | + job = viewModelScope.launch { | |
| 70 | + try { | |
| 71 | + state = Load.Ready(fetch()) | |
| 72 | + } catch (e: CancellationException) { | |
| 73 | + throw e | |
| 74 | + } catch (e: Exception) { | |
| 75 | + // A failed refresh keeps what's on screen. | |
| 76 | + if (!refresh || state !is Load.Ready) state = Load.Failed(describe(e)) | |
| 77 | + } finally { | |
| 78 | + refreshing = false | |
| 79 | + } | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + fun set(data: T) { | |
| 84 | + job?.cancel() | |
| 85 | + refreshing = false | |
| 86 | + state = Load.Ready(data) | |
| 87 | + } | |
| 88 | + | |
| 89 | + val data: T? get() = (state as? Load.Ready<T>)?.data | |
| 90 | + } | |
| 91 | + | |
| 92 | + @Composable | |
| 93 | + fun <T> rememberLoader(key: String, fetch: suspend () -> T): Loader<T> { | |
| 94 | + @Suppress("UNCHECKED_CAST") | |
| 95 | + return viewModel<Loader<*>>(key = key) { Loader(fetch) } as Loader<T> | |
| 96 | + } | |
| 97 | + | |
| 98 | + /** A list that loads page by page ("Load more"), kept like a [Loader]. */ | |
| 99 | + class Pager<T>(private val fetch: suspend (page: Int) -> Pair<List<T>, Boolean>) : ViewModel() { | |
| 100 | + val items = mutableStateListOf<T>() | |
| 101 | + var hasMore by mutableStateOf(true) | |
| 102 | + private set | |
| 103 | + var loading by mutableStateOf(false) | |
| 104 | + private set | |
| 105 | + var error by mutableStateOf<String?>(null) | |
| 106 | + private set | |
| 107 | + var loaded by mutableStateOf(false) | |
| 108 | + private set | |
| 109 | + private var page = 0 | |
| 110 | + private var job: Job? = null | |
| 111 | + | |
| 112 | + init { | |
| 113 | + more() | |
| 114 | + } | |
| 115 | + | |
| 116 | + fun more() { | |
| 117 | + if (loading || !hasMore) return | |
| 118 | + loading = true | |
| 119 | + job = viewModelScope.launch { | |
| 120 | + try { | |
| 121 | + val (next, more) = fetch(page + 1) | |
| 122 | + page += 1 | |
| 123 | + items.addAll(next) | |
| 124 | + hasMore = more | |
| 125 | + error = null | |
| 126 | + loaded = true | |
| 127 | + } catch (e: CancellationException) { | |
| 128 | + throw e | |
| 129 | + } catch (e: Exception) { | |
| 130 | + error = describe(e) | |
| 131 | + } finally { | |
| 132 | + loading = false | |
| 133 | + } | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + fun refresh() { | |
| 138 | + job?.cancel() | |
| 139 | + loading = false | |
| 140 | + page = 0 | |
| 141 | + hasMore = true | |
| 142 | + viewModelScope.launch { | |
| 143 | + try { | |
| 144 | + val (first, more) = fetch(1) | |
| 145 | + items.clear() | |
| 146 | + items.addAll(first) | |
| 147 | + page = 1 | |
| 148 | + hasMore = more | |
| 149 | + error = null | |
| 150 | + loaded = true | |
| 151 | + } catch (e: CancellationException) { | |
| 152 | + throw e | |
| 153 | + } catch (e: Exception) { | |
| 154 | + error = describe(e) | |
| 155 | + } | |
| 156 | + } | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 160 | + @Composable | |
| 161 | + fun <T> rememberPager(key: String, fetch: suspend (page: Int) -> Pair<List<T>, Boolean>): Pager<T> { | |
| 162 | + @Suppress("UNCHECKED_CAST") | |
| 163 | + return viewModel<Pager<*>>(key = key) { Pager(fetch) } as Pager<T> | |
| 164 | + } | |
| 165 | + | |
| 166 | + /** Runs `onReturn` when the screen comes back into view (after a screen on top of it closes), not on first show. */ | |
| 167 | + @Composable | |
| 168 | + fun RefreshOnReturn(onReturn: () -> Unit) { | |
| 169 | + val first = remember { mutableStateOf(true) } | |
| 170 | + val action by rememberUpdatedState(onReturn) | |
| 171 | + LifecycleResumeEffect(Unit) { | |
| 172 | + if (first.value) first.value = false else action() | |
| 173 | + onPauseOrDispose {} | |
| 174 | + } | |
| 175 | + } | |
| @@ -0,0 +1,311 @@ | ||
| 1 | + package cc.vectrum.app.ui.components | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.horizontalScroll | |
| 4 | + import androidx.compose.foundation.layout.Box | |
| 5 | + import androidx.compose.foundation.layout.Column | |
| 6 | + import androidx.compose.foundation.layout.PaddingValues | |
| 7 | + import androidx.compose.foundation.layout.Row | |
| 8 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 9 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 10 | + import androidx.compose.foundation.layout.padding | |
| 11 | + import androidx.compose.foundation.layout.requiredWidth | |
| 12 | + import androidx.compose.foundation.layout.width | |
| 13 | + import androidx.compose.foundation.lazy.LazyColumn | |
| 14 | + import androidx.compose.foundation.lazy.LazyListState | |
| 15 | + import androidx.compose.foundation.lazy.itemsIndexed | |
| 16 | + import androidx.compose.foundation.lazy.rememberLazyListState | |
| 17 | + import androidx.compose.foundation.rememberScrollState | |
| 18 | + import androidx.compose.foundation.text.selection.SelectionContainer | |
| 19 | + import androidx.compose.material3.Text | |
| 20 | + import androidx.compose.runtime.Composable | |
| 21 | + import androidx.compose.runtime.getValue | |
| 22 | + import androidx.compose.runtime.produceState | |
| 23 | + import androidx.compose.runtime.remember | |
| 24 | + import androidx.compose.ui.Modifier | |
| 25 | + import androidx.compose.ui.graphics.Color | |
| 26 | + import androidx.compose.ui.platform.LocalDensity | |
| 27 | + import androidx.compose.ui.text.AnnotatedString | |
| 28 | + import androidx.compose.ui.text.SpanStyle | |
| 29 | + import androidx.compose.ui.text.buildAnnotatedString | |
| 30 | + import androidx.compose.ui.text.font.FontStyle | |
| 31 | + import androidx.compose.ui.text.rememberTextMeasurer | |
| 32 | + import androidx.compose.ui.text.style.TextAlign | |
| 33 | + import androidx.compose.ui.text.withStyle | |
| 34 | + import androidx.compose.ui.unit.dp | |
| 35 | + import cc.vectrum.app.ui.theme.Syntax | |
| 36 | + import kotlinx.coroutines.Dispatchers | |
| 37 | + import kotlinx.coroutines.withContext | |
| 38 | + import cc.vectrum.app.ui.theme.Vectrum | |
| 39 | + | |
| 40 | + /* | |
| 41 | + * A small highlighter in MetalUI's syntax colours (--mu-syn-*): comments, strings, numbers, keywords, | |
| 42 | + * types and calls, for the languages people keep in repositories. Not a parser; it's there to make | |
| 43 | + * code easy to scan on a phone, as Shiki does on the website. | |
| 44 | + */ | |
| 45 | + | |
| 46 | + private enum class Family { CLike, Python, Shell, Ruby, Sql, Markup, Css, Yaml, Toml, Json, Plain } | |
| 47 | + | |
| 48 | + private fun familyOf(lang: String): Family = when (lang.lowercase()) { | |
| 49 | + "js", "mjs", "cjs", "jsx", "ts", "tsx", "javascript", "typescript", "java", "kt", "kts", "kotlin", "c", "h", "cc", "cpp", "hpp", "cs", | |
| 50 | + "go", "rs", "rust", "swift", "scala", "dart", "php", "groovy", "gradle", "zig", "m", "mm", "proto", "graphql", "gql", "sol" -> Family.CLike | |
| 51 | + "py", "python", "pyi" -> Family.Python | |
| 52 | + "sh", "bash", "zsh", "fish", "shell", "console", "dockerfile", "makefile", "mk", "env", "ps1" -> Family.Shell | |
| 53 | + "rb", "ruby", "ex", "exs", "elixir", "lua", "pl", "perl", "r" -> Family.Ruby | |
| 54 | + "sql" -> Family.Sql | |
| 55 | + "html", "htm", "xml", "svg", "vue", "svelte", "astro", "plist" -> Family.Markup | |
| 56 | + "css", "scss", "sass", "less" -> Family.Css | |
| 57 | + "yml", "yaml" -> Family.Yaml | |
| 58 | + "toml", "ini", "cfg", "conf", "editorconfig", "properties" -> Family.Toml | |
| 59 | + "json", "jsonc", "json5", "lock" -> Family.Json | |
| 60 | + else -> Family.Plain | |
| 61 | + } | |
| 62 | + | |
| 63 | + /** The language of a file by its name, for highlighting ("" when unknown). */ | |
| 64 | + fun languageOf(path: String): String { | |
| 65 | + val name = path.substringAfterLast('/') | |
| 66 | + return when { | |
| 67 | + name.equals("Dockerfile", true) -> "dockerfile" | |
| 68 | + name.equals("Makefile", true) -> "makefile" | |
| 69 | + name.startsWith(".env") -> "env" | |
| 70 | + name.startsWith(".") && !name.substring(1).contains('.') -> name.substring(1) | |
| 71 | + else -> name.substringAfterLast('.', "") | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + private val KEYWORDS = setOf( | |
| 76 | + // C-like | |
| 77 | + "abstract", "as", "async", "await", "break", "case", "catch", "class", "const", "continue", "data", "debugger", "default", "defer", "delete", | |
| 78 | + "do", "else", "enum", "export", "extends", "false", "final", "finally", "fn", "for", "from", "fun", "func", "function", "go", "if", "impl", | |
| 79 | + "implements", "import", "in", "instanceof", "interface", "internal", "is", "let", "match", "mod", "module", "mut", "namespace", "new", "nil", | |
| 80 | + "null", "object", "of", "open", "override", "package", "private", "protected", "pub", "public", "readonly", "return", "sealed", "self", | |
| 81 | + "static", "struct", "super", "suspend", "switch", "this", "throw", "throws", "trait", "true", "try", "type", "typeof", "undefined", "use", | |
| 82 | + "val", "var", "void", "when", "where", "while", "with", "yield", "crate", "dyn", "loop", "ref", "unsafe", "chan", "select", "range", "map", | |
| 83 | + "satisfies", "keyof", "declare", "lateinit", "companion", "init", "inline", "reified", "operator", "infix", "guard", "inout", "extension", | |
| 84 | + "protocol", "struct", "char", "int", "long", "float", "double", "bool", "boolean", "string", "unsigned", "signed", "goto", "sizeof", | |
| 85 | + // Python, Ruby, shell | |
| 86 | + "and", "def", "del", "elif", "except", "global", "lambda", "nonlocal", "not", "or", "pass", "raise", "None", "True", "False", "end", | |
| 87 | + "begin", "rescue", "ensure", "unless", "until", "then", "fi", "esac", "done", "local", "export", "echo", "require", "elsif", "puts", | |
| 88 | + ) | |
| 89 | + private val SQL_KEYWORDS = setOf( | |
| 90 | + "select", "from", "where", "insert", "into", "values", "update", "set", "delete", "create", "table", "drop", "alter", "add", "join", "left", | |
| 91 | + "right", "inner", "outer", "on", "group", "by", "order", "having", "limit", "offset", "and", "or", "not", "null", "is", "as", "distinct", | |
| 92 | + "primary", "key", "foreign", "references", "index", "unique", "default", "union", "all", "case", "when", "then", "else", "end", "in", "like", | |
| 93 | + ) | |
| 94 | + | |
| 95 | + // Matched at a position (Regex.matchAt), so no "^" anchors. | |
| 96 | + private val NUMBER = Regex("0x[0-9a-fA-F_]+|\\d[\\d_]*(\\.\\d+)?([eE][+-]?\\d+)?[a-zA-Z]*") | |
| 97 | + private val WORD = Regex("[A-Za-z_\$][A-Za-z0-9_\$]*") | |
| 98 | + private val TAG = Regex("</?[A-Za-z][\\w:.-]*") | |
| 99 | + private val YAML_KEY = Regex("[\\w.-]+(?=:(\\s|$))") | |
| 100 | + private val HEX_COLOR = Regex("#[0-9a-fA-F]{3,8}\\b") | |
| 101 | + private val DECORATOR = Regex("@[\\w.]+") | |
| 102 | + | |
| 103 | + /** Lines longer than this are shown uncoloured. */ | |
| 104 | + private const val MAX_LINE = 2_000 | |
| 105 | + | |
| 106 | + /** Files larger than this are shown uncoloured. */ | |
| 107 | + private const val MAX_HIGHLIGHT = 512 * 1024 | |
| 108 | + | |
| 109 | + /** Highlights code line by line, carrying open block comments and multi-line strings across lines. */ | |
| 110 | + class Highlighter(lang: String, private val syn: Syntax) { | |
| 111 | + private val family = familyOf(lang) | |
| 112 | + private var inBlockComment = false | |
| 113 | + private var inTripleString: String? = null | |
| 114 | + | |
| 115 | + private val lineComment: List<String> = when (family) { | |
| 116 | + Family.CLike -> listOf("//") | |
| 117 | + Family.Python, Family.Shell, Family.Ruby, Family.Yaml, Family.Toml -> listOf("#") | |
| 118 | + Family.Sql -> listOf("--") | |
| 119 | + Family.Css -> emptyList() | |
| 120 | + Family.Json -> listOf("//") | |
| 121 | + else -> emptyList() | |
| 122 | + } | |
| 123 | + private val blockComment: Pair<String, String>? = when (family) { | |
| 124 | + Family.CLike, Family.Css, Family.Json -> "/*" to "*/" | |
| 125 | + Family.Markup -> "<!--" to "-->" | |
| 126 | + else -> null | |
| 127 | + } | |
| 128 | + | |
| 129 | + fun line(text: String): AnnotatedString = buildAnnotatedString { | |
| 130 | + // Very long lines (minified code, data) aren't worth colouring and would slow the screen. | |
| 131 | + if (family == Family.Plain || text.length > MAX_LINE) { | |
| 132 | + withStyle(SpanStyle(color = syn.text)) { append(text) } | |
| 133 | + return@buildAnnotatedString | |
| 134 | + } | |
| 135 | + var i = 0 | |
| 136 | + val n = text.length | |
| 137 | + val indentEnd = text.indexOfFirst { !it.isWhitespace() }.let { if (it < 0) n else it } | |
| 138 | + fun emit(end: Int, color: Color, italic: Boolean = false) { | |
| 139 | + withStyle(SpanStyle(color = color, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal)) { append(text, i, end) } | |
| 140 | + i = end | |
| 141 | + } | |
| 142 | + /** The first character after `from` that isn't a space, or 0. */ | |
| 143 | + fun nextChar(from: Int): Char { | |
| 144 | + var j = from | |
| 145 | + while (j < n && text[j].isWhitespace()) j++ | |
| 146 | + return if (j < n) text[j] else 0.toChar() | |
| 147 | + } | |
| 148 | + fun match(regex: Regex): Int = regex.matchAt(text, i)?.value?.length ?: 0 | |
| 149 | + | |
| 150 | + while (i < n) { | |
| 151 | + // Inside a block comment or a triple-quoted string from an earlier line. | |
| 152 | + if (inBlockComment) { | |
| 153 | + val close = blockComment!!.second | |
| 154 | + val end = text.indexOf(close, i) | |
| 155 | + if (end < 0) { emit(n, syn.comment, true); break } | |
| 156 | + emit(end + close.length, syn.comment, true) | |
| 157 | + inBlockComment = false | |
| 158 | + continue | |
| 159 | + } | |
| 160 | + val open = inTripleString | |
| 161 | + if (open != null) { | |
| 162 | + val end = text.indexOf(open, i) | |
| 163 | + if (end < 0) { emit(n, syn.string); break } | |
| 164 | + emit(end + open.length, syn.string) | |
| 165 | + inTripleString = null | |
| 166 | + continue | |
| 167 | + } | |
| 168 | + val ch = text[i] | |
| 169 | + when { | |
| 170 | + lineComment.any { text.startsWith(it, i) } && !(family == Family.Shell && i > 0 && text[i - 1] == '$') -> emit(n, syn.comment, true) | |
| 171 | + blockComment != null && text.startsWith(blockComment.first, i) -> { | |
| 172 | + val end = text.indexOf(blockComment.second, i + blockComment.first.length) | |
| 173 | + if (end < 0) { inBlockComment = true; emit(n, syn.comment, true) } else emit(end + blockComment.second.length, syn.comment, true) | |
| 174 | + } | |
| 175 | + (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) && (family == Family.Python || family == Family.CLike || family == Family.Toml) -> { | |
| 176 | + val q = text.substring(i, i + 3) | |
| 177 | + val end = text.indexOf(q, i + 3) | |
| 178 | + if (end < 0) { inTripleString = q; emit(n, syn.string) } else emit(end + 3, syn.string) | |
| 179 | + } | |
| 180 | + ch == '"' || ch == '\'' || ch == '`' -> { | |
| 181 | + var j = i + 1 | |
| 182 | + while (j < n && text[j] != ch) j += if (text[j] == '\\') 2 else 1 | |
| 183 | + val end = minOf(j + 1, n) | |
| 184 | + // In JSON, a string before a colon is a key. | |
| 185 | + emit(end, if (family == Family.Json && nextChar(end) == ':') syn.member else syn.string) | |
| 186 | + } | |
| 187 | + family == Family.Markup && ch == '<' -> { | |
| 188 | + val len = match(TAG) | |
| 189 | + if (len > 0) emit(i + len, syn.keyword) else emit(i + 1, syn.punct) | |
| 190 | + } | |
| 191 | + family == Family.Yaml && i == indentEnd && match(YAML_KEY) > 0 -> emit(i + match(YAML_KEY), syn.member) | |
| 192 | + family == Family.Toml && ch == '[' && i == indentEnd -> emit(n, syn.type) | |
| 193 | + family == Family.Css && ch == '#' && match(HEX_COLOR) > 0 -> emit(i + match(HEX_COLOR), syn.number) | |
| 194 | + ch.isDigit() -> emit(i + maxOf(1, match(NUMBER)), syn.number) | |
| 195 | + ch == '@' && family != Family.Markup -> emit(i + maxOf(1, match(DECORATOR)), syn.type) | |
| 196 | + ch.isLetter() || ch == '_' || ch == '$' -> { | |
| 197 | + val len = maxOf(1, match(WORD)) | |
| 198 | + val word = text.substring(i, i + len) | |
| 199 | + val after = i + len | |
| 200 | + val color = when { | |
| 201 | + family == Family.Sql && word.lowercase() in SQL_KEYWORDS -> syn.keyword | |
| 202 | + family != Family.Sql && word in KEYWORDS -> syn.keyword | |
| 203 | + family == Family.Shell && ch == '$' -> syn.member | |
| 204 | + after < n && text[after] == '(' -> syn.member | |
| 205 | + word[0].isUpperCase() && word.length > 1 -> syn.type | |
| 206 | + family == Family.Toml && i == indentEnd && nextChar(after) == '=' -> syn.member | |
| 207 | + family == Family.Css && nextChar(after) == ':' -> syn.member | |
| 208 | + else -> syn.text | |
| 209 | + } | |
| 210 | + emit(after, color) | |
| 211 | + } | |
| 212 | + ch.isWhitespace() -> { | |
| 213 | + var j = i | |
| 214 | + while (j < n && text[j].isWhitespace()) j++ | |
| 215 | + emit(j, syn.text) | |
| 216 | + } | |
| 217 | + else -> emit(i + 1, syn.punct) | |
| 218 | + } | |
| 219 | + } | |
| 220 | + } | |
| 221 | + } | |
| 222 | + | |
| 223 | + /** | |
| 224 | + * Highlighted lines of a whole text. The plain lines show at once; the colours are worked out away from | |
| 225 | + * the main thread and swapped in (and skipped for very large files). | |
| 226 | + */ | |
| 227 | + @Composable | |
| 228 | + fun rememberHighlighted(text: String, lang: String): List<AnnotatedString> { | |
| 229 | + val syn = Vectrum.colors.syntax | |
| 230 | + val plain = remember(text) { | |
| 231 | + text.removeSuffix("\n").split('\n').map { line -> AnnotatedString(line.replace("\t", " "), SpanStyle(color = syn.text)) } | |
| 232 | + } | |
| 233 | + val colored by produceState(plain, text, lang, syn) { | |
| 234 | + if (text.length <= MAX_HIGHLIGHT) { | |
| 235 | + value = withContext(Dispatchers.Default) { | |
| 236 | + val h = Highlighter(lang, syn) | |
| 237 | + plain.map { h.line(it.text) } | |
| 238 | + } | |
| 239 | + } | |
| 240 | + } | |
| 241 | + return colored | |
| 242 | + } | |
| 243 | + | |
| 244 | + /** | |
| 245 | + * A file's code with line numbers, like the website's code view. Lines scroll sideways together (or | |
| 246 | + * wrap), and the list is lazy, so long files stay smooth. | |
| 247 | + */ | |
| 248 | + @Composable | |
| 249 | + fun CodeView( | |
| 250 | + text: String, | |
| 251 | + lang: String, | |
| 252 | + modifier: Modifier = Modifier, | |
| 253 | + wrap: Boolean = false, | |
| 254 | + state: LazyListState = rememberLazyListState(), | |
| 255 | + contentPadding: PaddingValues = PaddingValues(vertical = 12.dp), | |
| 256 | + header: (@Composable () -> Unit)? = null, | |
| 257 | + ) { | |
| 258 | + val c = Vectrum.colors | |
| 259 | + val style = Vectrum.type.code | |
| 260 | + val lines = rememberHighlighted(text, lang) | |
| 261 | + val measurer = rememberTextMeasurer() | |
| 262 | + val density = LocalDensity.current | |
| 263 | + val charWidth = remember(style, density) { measurer.measure("0000000000", style).size.width / 10f } | |
| 264 | + val gutter = with(density) { (charWidth * (lines.size.toString().length + 1)).toDp() } + 18.dp | |
| 265 | + val longest = remember(lines) { lines.maxOfOrNull { it.length } ?: 0 } | |
| 266 | + // Compose can't lay out anything wider than about 262,000 px, so files with very long lines always wrap. | |
| 267 | + val forceWrap = longest > MAX_LINE | |
| 268 | + val wrapped = wrap || forceWrap | |
| 269 | + val contentWidth = gutter + with(density) { (charWidth * longest).toDp() } + 40.dp | |
| 270 | + val hScroll = rememberScrollState() | |
| 271 | + | |
| 272 | + BoxWithWidth(modifier.fillMaxSize()) { viewport -> | |
| 273 | + val width = if (wrapped) viewport else maxOf(viewport, contentWidth) | |
| 274 | + Box(if (wrapped) Modifier.fillMaxSize() else Modifier.fillMaxSize().horizontalScroll(hScroll)) { | |
| 275 | + SelectionContainer { | |
| 276 | + LazyColumn(Modifier.requiredWidth(width).fillMaxSize(), state = state, contentPadding = contentPadding) { | |
| 277 | + if (header != null) item { Box(Modifier.width(viewport)) { header() } } | |
| 278 | + itemsIndexed(lines) { index, line -> | |
| 279 | + Row(Modifier.fillMaxWidth()) { | |
| 280 | + Text( | |
| 281 | + "${index + 1}", | |
| 282 | + modifier = Modifier.width(gutter).padding(end = 14.dp), | |
| 283 | + style = style, | |
| 284 | + color = c.syntax.line, | |
| 285 | + textAlign = TextAlign.End, | |
| 286 | + maxLines = 1, | |
| 287 | + ) | |
| 288 | + Text(line, style = style, softWrap = wrapped, modifier = Modifier.padding(end = 16.dp)) | |
| 289 | + } | |
| 290 | + } | |
| 291 | + } | |
| 292 | + } | |
| 293 | + } | |
| 294 | + } | |
| 295 | + } | |
| 296 | + | |
| 297 | + /** Measures the width it's given and hands it to its content. */ | |
| 298 | + @Composable | |
| 299 | + private fun BoxWithWidth(modifier: Modifier, content: @Composable (androidx.compose.ui.unit.Dp) -> Unit) { | |
| 300 | + androidx.compose.foundation.layout.BoxWithConstraints(modifier) { content(maxWidth) } | |
| 301 | + } | |
| 302 | + | |
| 303 | + /** A short block of highlighted code on a well (markdown code blocks, snippets). */ | |
| 304 | + @Composable | |
| 305 | + fun CodeBlock(text: String, lang: String, modifier: Modifier = Modifier) { | |
| 306 | + val lines = rememberHighlighted(text, lang) | |
| 307 | + Column(modifier.horizontalScroll(rememberScrollState())) { | |
| 308 | + // Lines past MAX_LINE are cut: in a sideways scroll they could be wider than Compose can lay out. | |
| 309 | + for (line in lines) Text(if (line.length > MAX_LINE) line.subSequence(0, MAX_LINE) else line, style = Vectrum.type.code, softWrap = false) | |
| 310 | + } | |
| 311 | + } | |
| @@ -0,0 +1,318 @@ | ||
| 1 | + package cc.vectrum.app.ui.components | |
| 2 | + | |
| 3 | + import androidx.compose.animation.animateColorAsState | |
| 4 | + import androidx.compose.runtime.mutableStateOf | |
| 5 | + import androidx.compose.runtime.setValue | |
| 6 | + import androidx.compose.ui.unit.Constraints | |
| 7 | + import androidx.compose.ui.layout.layout | |
| 8 | + import androidx.compose.ui.geometry.Offset | |
| 9 | + import androidx.compose.animation.core.VectorConverter | |
| 10 | + import androidx.compose.animation.core.Animatable | |
| 11 | + import androidx.compose.animation.core.Spring | |
| 12 | + import androidx.compose.animation.core.animateFloatAsState | |
| 13 | + import androidx.compose.animation.core.spring | |
| 14 | + import androidx.compose.foundation.background | |
| 15 | + import androidx.compose.foundation.clickable | |
| 16 | + import androidx.compose.foundation.interaction.MutableInteractionSource | |
| 17 | + import androidx.compose.foundation.interaction.collectIsPressedAsState | |
| 18 | + import androidx.compose.foundation.layout.Arrangement | |
| 19 | + import androidx.compose.foundation.layout.Box | |
| 20 | + import androidx.compose.foundation.layout.PaddingValues | |
| 21 | + import androidx.compose.foundation.layout.Row | |
| 22 | + import androidx.compose.foundation.layout.Spacer | |
| 23 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 24 | + import androidx.compose.foundation.layout.height | |
| 25 | + import androidx.compose.foundation.layout.padding | |
| 26 | + import androidx.compose.foundation.layout.size | |
| 27 | + import androidx.compose.foundation.layout.width | |
| 28 | + import androidx.compose.foundation.rememberScrollState | |
| 29 | + import androidx.compose.foundation.horizontalScroll | |
| 30 | + import androidx.compose.foundation.shape.CircleShape | |
| 31 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 32 | + import androidx.compose.material3.CircularProgressIndicator | |
| 33 | + import androidx.compose.material3.Icon | |
| 34 | + import androidx.compose.material3.Text | |
| 35 | + import androidx.compose.runtime.Composable | |
| 36 | + import androidx.compose.runtime.LaunchedEffect | |
| 37 | + import androidx.compose.runtime.getValue | |
| 38 | + import androidx.compose.runtime.mutableStateMapOf | |
| 39 | + import androidx.compose.runtime.remember | |
| 40 | + import androidx.compose.ui.Alignment | |
| 41 | + import androidx.compose.ui.Modifier | |
| 42 | + import androidx.compose.ui.graphics.Brush | |
| 43 | + import androidx.compose.ui.graphics.Color | |
| 44 | + import androidx.compose.ui.graphics.Shape | |
| 45 | + import androidx.compose.ui.graphics.graphicsLayer | |
| 46 | + import androidx.compose.ui.graphics.vector.ImageVector | |
| 47 | + import androidx.compose.ui.hapticfeedback.HapticFeedbackType | |
| 48 | + import androidx.compose.ui.layout.onGloballyPositioned | |
| 49 | + import androidx.compose.ui.layout.positionInParent | |
| 50 | + import androidx.compose.ui.platform.LocalHapticFeedback | |
| 51 | + import androidx.compose.ui.semantics.Role | |
| 52 | + import androidx.compose.ui.text.style.TextOverflow | |
| 53 | + import androidx.compose.ui.unit.Dp | |
| 54 | + import androidx.compose.ui.unit.dp | |
| 55 | + import cc.vectrum.app.ui.theme.Radii | |
| 56 | + import cc.vectrum.app.ui.theme.Vectrum | |
| 57 | + import cc.vectrum.app.ui.theme.material | |
| 58 | + import cc.vectrum.app.ui.theme.materials | |
| 59 | + import cc.vectrum.app.ui.theme.raised | |
| 60 | + import cc.vectrum.app.ui.theme.well | |
| 61 | + import kotlin.math.roundToInt | |
| 62 | + | |
| 63 | + enum class ButtonKind { Standard, Primary, Danger, Ghost } | |
| 64 | + | |
| 65 | + /** Spring of MetalUI's caps: quick, a hint of overshoot. */ | |
| 66 | + private val pressSpring = spring<Float>(dampingRatio = 0.6f, stiffness = Spring.StiffnessHigh) | |
| 67 | + | |
| 68 | + /** | |
| 69 | + * A cap (MetalUI's Button): soft-touch standard, the dark primary, the red destructive, or a quiet | |
| 70 | + * ghost key. It travels down and sinks while held, with a tick of haptics. | |
| 71 | + */ | |
| 72 | + @Composable | |
| 73 | + fun VButton( | |
| 74 | + text: String, | |
| 75 | + onClick: () -> Unit, | |
| 76 | + modifier: Modifier = Modifier, | |
| 77 | + kind: ButtonKind = ButtonKind.Standard, | |
| 78 | + icon: ImageVector? = null, | |
| 79 | + enabled: Boolean = true, | |
| 80 | + loading: Boolean = false, | |
| 81 | + small: Boolean = false, | |
| 82 | + fill: Boolean = false, | |
| 83 | + ) { | |
| 84 | + val c = Vectrum.colors | |
| 85 | + val interaction = remember { MutableInteractionSource() } | |
| 86 | + val pressed by interaction.collectIsPressedAsState() | |
| 87 | + val haptics = LocalHapticFeedback.current | |
| 88 | + val travel by animateFloatAsState(if (pressed && enabled) 1f else 0f, pressSpring, label = "travel") | |
| 89 | + val m = c.materials | |
| 90 | + val shape = Radii.pill | |
| 91 | + // Disabled caps sink into a well with faded ink (an alpha layer would clip their shadows). | |
| 92 | + val (material, brush, ink) = when { | |
| 93 | + !enabled && kind != ButtonKind.Ghost -> Triple(m.well, Brush.verticalGradient(listOf(c.wellTop, c.wellBottom)), c.ink3) | |
| 94 | + kind == ButtonKind.Standard -> Triple(if (pressed) m.pressed else m.button, Brush.verticalGradient(if (pressed) listOf(c.pressedTop, c.pressedBottom) else listOf(c.buttonTop, c.buttonBottom)), c.ink) | |
| 95 | + kind == ButtonKind.Primary -> Triple(m.primary, Brush.verticalGradient(listOf(c.primaryTop, c.primaryBottom)), c.primaryInk) | |
| 96 | + kind == ButtonKind.Danger -> Triple(m.primary, Brush.verticalGradient(listOf(c.dangerTop, c.dangerBottom)), Color.White) | |
| 97 | + else -> Triple(null, Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)), if (enabled) c.ink2 else c.ink3) | |
| 98 | + } | |
| 99 | + val height = if (small) 34.dp else 44.dp | |
| 100 | + val base = modifier | |
| 101 | + .graphicsLayer { translationY = travel * 1.dp.toPx(); scaleX = 1f - travel * 0.015f; scaleY = 1f - travel * 0.015f } | |
| 102 | + .height(height) | |
| 103 | + .let { if (fill) it.fillMaxWidth() else it } | |
| 104 | + val surface = if (material != null) base.material(material, shape, brush) else base.background(if (pressed) c.rowHover else Color.Transparent, shape) | |
| 105 | + Row( | |
| 106 | + surface | |
| 107 | + .clickable(interaction, indication = null, enabled = enabled && !loading, role = Role.Button) { | |
| 108 | + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) | |
| 109 | + onClick() | |
| 110 | + } | |
| 111 | + .padding(horizontal = if (small) 13.dp else 18.dp), | |
| 112 | + verticalAlignment = Alignment.CenterVertically, | |
| 113 | + horizontalArrangement = Arrangement.spacedBy(7.dp, Alignment.CenterHorizontally), | |
| 114 | + ) { | |
| 115 | + if (loading) { | |
| 116 | + CircularProgressIndicator(Modifier.size(15.dp), color = ink, strokeWidth = 1.7.dp) | |
| 117 | + } else if (icon != null) { | |
| 118 | + Icon(icon, null, Modifier.size(if (small) 16.dp else 17.dp), tint = ink) | |
| 119 | + } | |
| 120 | + Text(text, style = Vectrum.type.ui, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis) | |
| 121 | + } | |
| 122 | + } | |
| 123 | + | |
| 124 | + /** A round key with a glyph: raised (back buttons, toolbar actions) or a quiet ghost. */ | |
| 125 | + @Composable | |
| 126 | + fun RoundButton( | |
| 127 | + icon: ImageVector, | |
| 128 | + contentDescription: String?, | |
| 129 | + onClick: () -> Unit, | |
| 130 | + modifier: Modifier = Modifier, | |
| 131 | + size: Dp = 40.dp, | |
| 132 | + ghost: Boolean = false, | |
| 133 | + primary: Boolean = false, | |
| 134 | + enabled: Boolean = true, | |
| 135 | + loading: Boolean = false, | |
| 136 | + tint: Color = if (primary) Vectrum.colors.primaryInk else Vectrum.colors.icon, | |
| 137 | + ) { | |
| 138 | + val c = Vectrum.colors | |
| 139 | + val interaction = remember { MutableInteractionSource() } | |
| 140 | + val pressed by interaction.collectIsPressedAsState() | |
| 141 | + val haptics = LocalHapticFeedback.current | |
| 142 | + val travel by animateFloatAsState(if (pressed) 1f else 0f, pressSpring, label = "travel") | |
| 143 | + val shape = CircleShape | |
| 144 | + val surface = when { | |
| 145 | + ghost -> Modifier.background(if (pressed) c.rowHover else Color.Transparent, shape) | |
| 146 | + !enabled -> Modifier.material(c.materials.well, shape, Brush.verticalGradient(listOf(c.wellTop, c.wellBottom))) | |
| 147 | + primary -> Modifier.material(c.materials.primary, shape, Brush.verticalGradient(listOf(c.primaryTop, c.primaryBottom))) | |
| 148 | + pressed -> Modifier.material(c.materials.pressed, shape, Brush.verticalGradient(listOf(c.pressedTop, c.pressedBottom))) | |
| 149 | + else -> Modifier.material(c.materials.button, shape, Brush.verticalGradient(listOf(c.buttonTop, c.buttonBottom))) | |
| 150 | + } | |
| 151 | + Box( | |
| 152 | + modifier | |
| 153 | + .size(size) | |
| 154 | + .graphicsLayer { translationY = travel * 1.dp.toPx() } | |
| 155 | + .then(surface) | |
| 156 | + .clickable(interaction, indication = null, enabled = enabled && !loading, role = Role.Button) { | |
| 157 | + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) | |
| 158 | + onClick() | |
| 159 | + }, | |
| 160 | + contentAlignment = Alignment.Center, | |
| 161 | + ) { | |
| 162 | + val ink = if (enabled || ghost) tint else c.ink3 | |
| 163 | + if (loading) { | |
| 164 | + CircularProgressIndicator(Modifier.size(size * 0.4f), color = ink, strokeWidth = 1.7.dp) | |
| 165 | + } else { | |
| 166 | + Icon(icon, contentDescription, Modifier.size(size * 0.46f), tint = ink) | |
| 167 | + } | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + data class SegmentOption<T>(val value: T, val label: String, val icon: ImageVector? = null, val count: Int? = null) | |
| 172 | + | |
| 173 | + /** | |
| 174 | + * MetalUI's Segmented control, as the website's repository tabs: a sunk track with a raised thumb | |
| 175 | + * that slides to the chosen option. Scrolls sideways when the options don't fit. | |
| 176 | + */ | |
| 177 | + @Composable | |
| 178 | + fun <T> Segmented( | |
| 179 | + options: List<SegmentOption<T>>, | |
| 180 | + selected: T, | |
| 181 | + onSelect: (T) -> Unit, | |
| 182 | + modifier: Modifier = Modifier, | |
| 183 | + scrollable: Boolean = false, | |
| 184 | + equalWidth: Boolean = !scrollable, | |
| 185 | + ) { | |
| 186 | + val c = Vectrum.colors | |
| 187 | + val haptics = LocalHapticFeedback.current | |
| 188 | + val bounds = remember { mutableStateMapOf<Int, Pair<Int, Int>>() } | |
| 189 | + val index = options.indexOfFirst { it.value == selected }.coerceAtLeast(0) | |
| 190 | + val scroll = rememberScrollState() | |
| 191 | + val target = bounds[index] | |
| 192 | + // The thumb's left and right edges, moved as one so they never drift apart mid-slide. It appears | |
| 193 | + // in place the first time (no growing out of the corner), then glides to each new choice. | |
| 194 | + val edges = remember { Animatable(Offset.Zero, Offset.VectorConverter) } | |
| 195 | + var placed by remember { mutableStateOf(false) } | |
| 196 | + LaunchedEffect(target) { | |
| 197 | + if (target == null) return@LaunchedEffect | |
| 198 | + val goal = Offset(target.first.toFloat(), (target.first + target.second).toFloat()) | |
| 199 | + if (!placed) { | |
| 200 | + edges.snapTo(goal) | |
| 201 | + placed = true | |
| 202 | + } else { | |
| 203 | + edges.animateTo(goal, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) | |
| 204 | + } | |
| 205 | + } | |
| 206 | + | |
| 207 | + LaunchedEffect(index, target) { | |
| 208 | + if (scrollable && target != null) { | |
| 209 | + val viewport = scroll.viewportSize | |
| 210 | + val center = target.first + target.second / 2 - viewport / 2 | |
| 211 | + scroll.animateScrollTo(center.coerceIn(0, scroll.maxValue)) | |
| 212 | + } | |
| 213 | + } | |
| 214 | + | |
| 215 | + val track = RoundedCornerShape(50) | |
| 216 | + Box(modifier.well(track).padding(3.dp)) { | |
| 217 | + val rowModifier = if (scrollable) Modifier.horizontalScroll(scroll) else Modifier.fillMaxWidth() | |
| 218 | + Box(rowModifier) { | |
| 219 | + if (placed) { | |
| 220 | + Box( | |
| 221 | + Modifier | |
| 222 | + .layout { measurable, _ -> | |
| 223 | + // Read in layout, so the slide doesn't recompose the control every frame. | |
| 224 | + val left = edges.value.x.roundToInt() | |
| 225 | + val width = (edges.value.y - edges.value.x).roundToInt().coerceAtLeast(0) | |
| 226 | + val thumb = measurable.measure(Constraints.fixed(width, 38.dp.roundToPx())) | |
| 227 | + layout(thumb.width, thumb.height) { thumb.place(left, 0) } | |
| 228 | + } | |
| 229 | + .raised(track), | |
| 230 | + ) | |
| 231 | + } | |
| 232 | + Row(if (scrollable) Modifier else Modifier.fillMaxWidth()) { | |
| 233 | + options.forEachIndexed { i, option -> | |
| 234 | + val active = i == index | |
| 235 | + val ink by animateColorAsState(if (active) c.ink else c.ink2, label = "ink") | |
| 236 | + Row( | |
| 237 | + Modifier | |
| 238 | + .let { if (equalWidth) it.weight(1f) else it } | |
| 239 | + .height(38.dp) | |
| 240 | + .onGloballyPositioned { bounds[i] = it.positionInParent().x.roundToInt() to it.size.width } | |
| 241 | + .clickable(remember { MutableInteractionSource() }, indication = null, role = Role.Tab) { | |
| 242 | + if (!active) haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) | |
| 243 | + onSelect(option.value) | |
| 244 | + } | |
| 245 | + .padding(horizontal = 14.dp), | |
| 246 | + verticalAlignment = Alignment.CenterVertically, | |
| 247 | + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), | |
| 248 | + ) { | |
| 249 | + if (option.icon != null) Icon(option.icon, null, Modifier.size(16.dp), tint = ink) | |
| 250 | + Text(option.label, style = Vectrum.type.ui, color = ink, maxLines = 1) | |
| 251 | + if (option.count != null) Counter(option.count) | |
| 252 | + } | |
| 253 | + } | |
| 254 | + } | |
| 255 | + } | |
| 256 | + } | |
| 257 | + } | |
| 258 | + | |
| 259 | + /** A row of choices as chips, for filters ("Open", "Merged", "Closed"). */ | |
| 260 | + @Composable | |
| 261 | + fun <T> ChipRow( | |
| 262 | + options: List<SegmentOption<T>>, | |
| 263 | + selected: T, | |
| 264 | + onSelect: (T) -> Unit, | |
| 265 | + modifier: Modifier = Modifier, | |
| 266 | + contentPadding: PaddingValues = PaddingValues(0.dp), | |
| 267 | + ) { | |
| 268 | + val c = Vectrum.colors | |
| 269 | + val haptics = LocalHapticFeedback.current | |
| 270 | + // Vertical room inside the scroller, so the chips' shadows aren't cut off by its clip. | |
| 271 | + Row( | |
| 272 | + modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(contentPadding).padding(vertical = 6.dp), | |
| 273 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 274 | + ) { | |
| 275 | + for (option in options) { | |
| 276 | + val active = option.value == selected | |
| 277 | + val shape: Shape = Radii.pill | |
| 278 | + Row( | |
| 279 | + (if (active) Modifier.material(c.materials.primary, shape, Brush.verticalGradient(listOf(c.primaryTop, c.primaryBottom))) else Modifier.raised(shape)) | |
| 280 | + .height(34.dp) | |
| 281 | + .clickable(remember { MutableInteractionSource() }, indication = null) { | |
| 282 | + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) | |
| 283 | + onSelect(option.value) | |
| 284 | + } | |
| 285 | + .padding(horizontal = 14.dp), | |
| 286 | + verticalAlignment = Alignment.CenterVertically, | |
| 287 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 288 | + ) { | |
| 289 | + val ink = if (active) c.primaryInk else c.ink | |
| 290 | + if (option.icon != null) Icon(option.icon, null, Modifier.size(15.dp), tint = ink) | |
| 291 | + Text(option.label, style = Vectrum.type.ui, color = ink) | |
| 292 | + if (option.count != null) Text("${option.count}", style = Vectrum.type.readout, color = if (active) c.primaryInk.copy(alpha = 0.7f) else c.ink3) | |
| 293 | + } | |
| 294 | + } | |
| 295 | + } | |
| 296 | + } | |
| 297 | + | |
| 298 | + /** A tappable row inside a plate: lifts out of it while pressed, like the website's list rows. */ | |
| 299 | + @Composable | |
| 300 | + fun PressableRow(onClick: (() -> Unit)?, modifier: Modifier = Modifier, content: @Composable () -> Unit) { | |
| 301 | + val c = Vectrum.colors | |
| 302 | + val interaction = remember { MutableInteractionSource() } | |
| 303 | + val pressed by interaction.collectIsPressedAsState() | |
| 304 | + val bg by animateColorAsState(if (pressed) c.rowHover else Color.Transparent, label = "row") | |
| 305 | + Box( | |
| 306 | + modifier | |
| 307 | + .background(bg) | |
| 308 | + .let { if (onClick != null) it.clickable(interaction, indication = null, onClick = onClick) else it }, | |
| 309 | + ) { content() } | |
| 310 | + } | |
| 311 | + | |
| 312 | + @Composable | |
| 313 | + fun Gap(size: Dp) = Spacer(Modifier.size(size)) | |
| 314 | + | |
| 315 | + /** Tappable without the ripple: the website's links and crumbs don't flash. */ | |
| 316 | + @Composable | |
| 317 | + fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = | |
| 318 | + clickable(remember { MutableInteractionSource() }, indication = null, onClick = onClick) | |
| @@ -0,0 +1,331 @@ | ||
| 1 | + package cc.vectrum.app.ui.components | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.Canvas | |
| 4 | + import androidx.compose.foundation.background | |
| 5 | + import androidx.compose.foundation.border | |
| 6 | + import androidx.compose.foundation.layout.Arrangement | |
| 7 | + import androidx.compose.foundation.layout.Box | |
| 8 | + import androidx.compose.foundation.layout.Column | |
| 9 | + import androidx.compose.foundation.layout.ColumnScope | |
| 10 | + import androidx.compose.foundation.layout.PaddingValues | |
| 11 | + import androidx.compose.foundation.layout.Row | |
| 12 | + import androidx.compose.foundation.layout.RowScope | |
| 13 | + import androidx.compose.foundation.layout.Spacer | |
| 14 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 15 | + import androidx.compose.foundation.layout.height | |
| 16 | + import androidx.compose.foundation.layout.padding | |
| 17 | + import androidx.compose.foundation.layout.size | |
| 18 | + import androidx.compose.foundation.layout.widthIn | |
| 19 | + import androidx.compose.foundation.shape.CircleShape | |
| 20 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 21 | + import androidx.compose.material3.Icon | |
| 22 | + import androidx.compose.material3.Text | |
| 23 | + import androidx.compose.runtime.Composable | |
| 24 | + import androidx.compose.runtime.remember | |
| 25 | + import androidx.compose.ui.Alignment | |
| 26 | + import androidx.compose.ui.Modifier | |
| 27 | + import androidx.compose.ui.draw.clip | |
| 28 | + import androidx.compose.ui.geometry.Offset | |
| 29 | + import androidx.compose.ui.geometry.Size | |
| 30 | + import androidx.compose.ui.graphics.Brush | |
| 31 | + import androidx.compose.ui.graphics.Color | |
| 32 | + import androidx.compose.ui.graphics.Shadow | |
| 33 | + import androidx.compose.ui.graphics.Shape | |
| 34 | + import androidx.compose.ui.graphics.lerp | |
| 35 | + import androidx.compose.ui.graphics.vector.ImageVector | |
| 36 | + import androidx.compose.ui.layout.ContentScale | |
| 37 | + import androidx.compose.ui.platform.LocalContext | |
| 38 | + import androidx.compose.ui.text.TextStyle | |
| 39 | + import androidx.compose.ui.text.font.FontWeight | |
| 40 | + import androidx.compose.ui.text.style.TextOverflow | |
| 41 | + import androidx.compose.ui.unit.Dp | |
| 42 | + import androidx.compose.ui.unit.dp | |
| 43 | + import androidx.compose.ui.unit.sp | |
| 44 | + import cc.vectrum.app.data.RepoSummary | |
| 45 | + import cc.vectrum.app.ui.LocalApi | |
| 46 | + import cc.vectrum.app.ui.icons.VIcons | |
| 47 | + import cc.vectrum.app.ui.theme.Radii | |
| 48 | + import cc.vectrum.app.ui.theme.Vectrum | |
| 49 | + import cc.vectrum.app.ui.theme.plate | |
| 50 | + import cc.vectrum.app.ui.theme.raised | |
| 51 | + import cc.vectrum.app.ui.theme.well | |
| 52 | + import coil3.compose.AsyncImage | |
| 53 | + import coil3.request.ImageRequest | |
| 54 | + import coil3.request.crossfade | |
| 55 | + | |
| 56 | + /** Engraved type: uppercase mono with a light lip, like the website's "ABOUT" and "PULL REQUEST · #8". */ | |
| 57 | + @Composable | |
| 58 | + fun Engraved(text: String, modifier: Modifier = Modifier, color: Color = Vectrum.colors.engrave, style: TextStyle = Vectrum.type.label) { | |
| 59 | + Text( | |
| 60 | + text.uppercase(), | |
| 61 | + modifier = modifier, | |
| 62 | + style = style.copy(shadow = Shadow(color = Vectrum.colors.lip, offset = Offset(0f, 1.5f), blurRadius = 0f)), | |
| 63 | + color = color, | |
| 64 | + maxLines = 1, | |
| 65 | + overflow = TextOverflow.Ellipsis, | |
| 66 | + ) | |
| 67 | + } | |
| 68 | + | |
| 69 | + /** An engraved hairline with its lip. */ | |
| 70 | + @Composable | |
| 71 | + fun Rule(modifier: Modifier = Modifier) { | |
| 72 | + val c = Vectrum.colors | |
| 73 | + Column(modifier.fillMaxWidth()) { | |
| 74 | + Box(Modifier.fillMaxWidth().height(1.dp).background(c.rule)) | |
| 75 | + Box(Modifier.fillMaxWidth().height(1.dp).background(c.ruleLip)) | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + /** A raised plate holding a block of content. */ | |
| 80 | + @Composable | |
| 81 | + fun Plate( | |
| 82 | + modifier: Modifier = Modifier, | |
| 83 | + shape: Shape = Radii.card, | |
| 84 | + padding: PaddingValues = PaddingValues(0.dp), | |
| 85 | + content: @Composable ColumnScope.() -> Unit, | |
| 86 | + ) { | |
| 87 | + Column(modifier.plate(shape).clip(shape).padding(padding), content = content) | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** A titled section: an engraved label (with an optional count and trailing action) above its plate. */ | |
| 91 | + @Composable | |
| 92 | + fun Section( | |
| 93 | + title: String, | |
| 94 | + modifier: Modifier = Modifier, | |
| 95 | + count: Int? = null, | |
| 96 | + trailing: (@Composable RowScope.() -> Unit)? = null, | |
| 97 | + content: @Composable ColumnScope.() -> Unit, | |
| 98 | + ) { | |
| 99 | + Column(modifier) { | |
| 100 | + Row(Modifier.fillMaxWidth().padding(start = 6.dp, end = 2.dp, bottom = 10.dp).height(28.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 101 | + Engraved(title) | |
| 102 | + if (count != null) { | |
| 103 | + Spacer(Modifier.size(8.dp)) | |
| 104 | + Counter(count) | |
| 105 | + } | |
| 106 | + Spacer(Modifier.weight(1f)) | |
| 107 | + trailing?.invoke(this) | |
| 108 | + } | |
| 109 | + content() | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 113 | + /** A small number beside a label: "Issues 5". */ | |
| 114 | + @Composable | |
| 115 | + fun Counter(n: Int, modifier: Modifier = Modifier) { | |
| 116 | + Text("$n", modifier = modifier, style = Vectrum.type.readout.copy(fontSize = 11.sp), color = Vectrum.colors.ink3) | |
| 117 | + } | |
| 118 | + | |
| 119 | + enum class LedKind { Live, Waiting, Danger, Off, Info } | |
| 120 | + | |
| 121 | + /** A lit status dot (MetalUI's LED): green live, amber waiting, red, unlit. */ | |
| 122 | + @Composable | |
| 123 | + fun Led(kind: LedKind, modifier: Modifier = Modifier, size: Dp = 7.dp) { | |
| 124 | + val (hi, mid, lo) = when (kind) { | |
| 125 | + LedKind.Live -> Triple(Color(0xFFD9FFE9), Color(0xFF7BEAAE), Color(0xFF2FB673)) | |
| 126 | + LedKind.Danger -> Triple(Color(0xFFFFD9D2), Color(0xFFFF7A66), Color(0xFFD5392A)) | |
| 127 | + LedKind.Waiting -> Triple(Color(0xFFFFF1CF), Color(0xFFF5BF55), Color(0xFFC98A18)) | |
| 128 | + LedKind.Info -> Triple(Color(0xFFD8E6FF), Color(0xFF6F9BFF), Color(0xFF2457F2)) | |
| 129 | + LedKind.Off -> Triple(Color(0xFF8B8B8E), Color(0xFF6A6A6D), Color(0xFF4A4A4D)) | |
| 130 | + } | |
| 131 | + Canvas(modifier.size(size)) { | |
| 132 | + val r = this.size.minDimension / 2 | |
| 133 | + drawCircle(Color.Black.copy(alpha = 0.25f), radius = r + 0.5f) | |
| 134 | + drawCircle( | |
| 135 | + Brush.radialGradient(0f to hi, 0.18f to hi, 0.45f to mid, 1f to lo, center = Offset(r * 0.8f, r * 0.7f), radius = r * 1.3f), | |
| 136 | + radius = r, | |
| 137 | + ) | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + enum class Tone { Default, Success, Attention, Danger, Accent, Merged } | |
| 142 | + | |
| 143 | + /** The website's Label: an engraved mono word on a raised pill, with its LED ("● PUBLIC", "● OPEN"). */ | |
| 144 | + @Composable | |
| 145 | + fun Pill(text: String, modifier: Modifier = Modifier, tone: Tone = Tone.Default, icon: ImageVector? = null) { | |
| 146 | + val c = Vectrum.colors | |
| 147 | + val ink = when (tone) { | |
| 148 | + Tone.Default -> c.ink2 | |
| 149 | + Tone.Success -> c.greenDeep | |
| 150 | + Tone.Attention -> c.amber | |
| 151 | + Tone.Danger -> c.red | |
| 152 | + Tone.Accent -> c.link | |
| 153 | + Tone.Merged -> c.merged | |
| 154 | + } | |
| 155 | + Row( | |
| 156 | + modifier.raised(Radii.pill).height(24.dp).padding(horizontal = 9.dp), | |
| 157 | + verticalAlignment = Alignment.CenterVertically, | |
| 158 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 159 | + ) { | |
| 160 | + if (icon != null) { | |
| 161 | + Icon(icon, null, Modifier.size(13.dp), tint = ink) | |
| 162 | + } else { | |
| 163 | + Led( | |
| 164 | + when (tone) { | |
| 165 | + Tone.Success -> LedKind.Live | |
| 166 | + Tone.Attention -> LedKind.Waiting | |
| 167 | + Tone.Danger -> LedKind.Danger | |
| 168 | + Tone.Accent, Tone.Merged -> LedKind.Info | |
| 169 | + Tone.Default -> LedKind.Off | |
| 170 | + }, | |
| 171 | + size = 6.dp, | |
| 172 | + ) | |
| 173 | + } | |
| 174 | + Text(text.uppercase(), style = Vectrum.type.label.copy(fontSize = 10.sp, fontWeight = FontWeight.Medium), color = if (tone == Tone.Default) c.ink2 else ink, maxLines = 1) | |
| 175 | + } | |
| 176 | + } | |
| 177 | + | |
| 178 | + /** A commit SHA or branch name, set in mono on a small well. */ | |
| 179 | + @Composable | |
| 180 | + fun RefChip(text: String, modifier: Modifier = Modifier, raisedChip: Boolean = false) { | |
| 181 | + val shape = RoundedCornerShape(7.dp) | |
| 182 | + Box( | |
| 183 | + (if (raisedChip) modifier.raised(shape) else modifier.well(shape)).padding(horizontal = 7.dp, vertical = 2.dp), | |
| 184 | + contentAlignment = Alignment.Center, | |
| 185 | + ) { | |
| 186 | + Text(text, style = Vectrum.type.readout, color = Vectrum.colors.ink2, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 220.dp)) | |
| 187 | + } | |
| 188 | + } | |
| 189 | + | |
| 190 | + /** An issue label: a lit swatch of its colour and its name on a hairline pill. */ | |
| 191 | + @Composable | |
| 192 | + fun LabelChip(name: String, color: String, modifier: Modifier = Modifier) { | |
| 193 | + val c = Vectrum.colors | |
| 194 | + Row( | |
| 195 | + modifier | |
| 196 | + .border(0.75.dp, c.rule, Radii.pill) | |
| 197 | + .background(c.surfaceHi.copy(alpha = 0.5f), Radii.pill) | |
| 198 | + .height(22.dp) | |
| 199 | + .padding(start = 7.dp, end = 9.dp), | |
| 200 | + verticalAlignment = Alignment.CenterVertically, | |
| 201 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 202 | + ) { | |
| 203 | + LabelDot(color, size = 7.dp) | |
| 204 | + Text(name, style = Vectrum.type.meta.copy(fontWeight = FontWeight.Medium), color = c.ink2, maxLines = 1) | |
| 205 | + } | |
| 206 | + } | |
| 207 | + | |
| 208 | + /** A label's colour as a small lit swatch: the colour under an LED's hairline ring and top light. */ | |
| 209 | + @Composable | |
| 210 | + fun LabelDot(color: String, modifier: Modifier = Modifier, size: Dp = 8.dp) { | |
| 211 | + val swatch = parseHex(color) ?: Vectrum.colors.ink3 | |
| 212 | + Canvas(modifier.size(size)) { | |
| 213 | + val r = this.size.minDimension / 2 | |
| 214 | + val lit = lerp(swatch, Color.White, 0.55f) | |
| 215 | + drawCircle(Color.Black.copy(alpha = 0.25f), radius = r + 0.5f) | |
| 216 | + drawCircle(Brush.radialGradient(0f to lit, 0.18f to lit, 0.6f to swatch, center = Offset(r * 0.8f, r * 0.7f), radius = r * 1.2f), radius = r) | |
| 217 | + } | |
| 218 | + } | |
| 219 | + | |
| 220 | + fun parseHex(hex: String): Color? { | |
| 221 | + val h = hex.removePrefix("#") | |
| 222 | + if (h.length != 6) return null | |
| 223 | + return h.toLongOrNull(16)?.let { Color(0xFF000000 or it) } | |
| 224 | + } | |
| 225 | + | |
| 226 | + /** Initials of a name, like MetalUI's avatars: "Alice Moreau" → AM, "bob" → B. */ | |
| 227 | + fun initialsOf(name: String): String { | |
| 228 | + val words = name.trim().split(Regex("[\\s._-]+")).filter { it.isNotEmpty() } | |
| 229 | + return when { | |
| 230 | + words.isEmpty() -> "?" | |
| 231 | + words.size == 1 -> words[0].take(1).uppercase() | |
| 232 | + else -> (words[0].take(1) + words[1].take(1)).uppercase() | |
| 233 | + } | |
| 234 | + } | |
| 235 | + | |
| 236 | + /** A person: a small raised disc with their initials. */ | |
| 237 | + @Composable | |
| 238 | + fun Avatar(name: String, modifier: Modifier = Modifier, size: Dp = 28.dp) { | |
| 239 | + Box(modifier.size(size).raised(CircleShape), contentAlignment = Alignment.Center) { | |
| 240 | + Text( | |
| 241 | + initialsOf(name), | |
| 242 | + style = Vectrum.type.ui.copy(fontSize = (size.value * 0.36f).sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp), | |
| 243 | + color = Vectrum.colors.ink2, | |
| 244 | + maxLines = 1, | |
| 245 | + ) | |
| 246 | + } | |
| 247 | + } | |
| 248 | + | |
| 249 | + /** An agent: the sparkle the website marks agents with, on a disc. */ | |
| 250 | + @Composable | |
| 251 | + fun AgentAvatar(modifier: Modifier = Modifier, size: Dp = 28.dp) { | |
| 252 | + Box(modifier.size(size).raised(CircleShape), contentAlignment = Alignment.Center) { | |
| 253 | + Icon(VIcons.Sparkle, null, Modifier.size(size * 0.5f), tint = Vectrum.colors.ink2) | |
| 254 | + } | |
| 255 | + } | |
| 256 | + | |
| 257 | + /** Seeded 32-bit FNV-1a with a final avalanche, exactly as components/ui.tsx does it. */ | |
| 258 | + private fun hash32(text: String, seed: Int = 0): Long { | |
| 259 | + var h = (0x811c9dc5.toInt() xor seed) | |
| 260 | + for (ch in text) { | |
| 261 | + h = h xor ch.code | |
| 262 | + h *= 0x01000193 | |
| 263 | + } | |
| 264 | + h = h xor (h ushr 16) | |
| 265 | + h *= 0x85ebca6b.toInt() | |
| 266 | + h = h xor (h ushr 13) | |
| 267 | + h *= 0xc2b2ae35.toInt() | |
| 268 | + h = h xor (h ushr 16) | |
| 269 | + return h.toLong() and 0xFFFFFFFFL | |
| 270 | + } | |
| 271 | + | |
| 272 | + /** The website's identicon: a horizontally mirrored 5×5 grid in one of MetalUI's tints. */ | |
| 273 | + @Composable | |
| 274 | + fun Identicon(name: String, modifier: Modifier = Modifier) { | |
| 275 | + val key = name.trim().lowercase() | |
| 276 | + val cells = remember(key) { | |
| 277 | + val bits = hash32(key) | |
| 278 | + buildList { | |
| 279 | + for (row in 0 until 5) for (col in 0 until 3) { | |
| 280 | + if (bits and (1L shl (row * 3 + col)) == 0L) continue | |
| 281 | + add(col to row) | |
| 282 | + if (col < 2) add((4 - col) to row) | |
| 283 | + } | |
| 284 | + }.ifEmpty { (0 until 5).map { 2 to it } } | |
| 285 | + } | |
| 286 | + val tints = Vectrum.colors.tints | |
| 287 | + val color = tints[(hash32(key, 0x9e3779b9.toInt()) % tints.size).toInt()] | |
| 288 | + Canvas(modifier) { | |
| 289 | + // viewBox -1.2 -1.2 7.4 7.4, like the website's. | |
| 290 | + val unit = size.minDimension / 7.4f | |
| 291 | + for ((x, y) in cells) { | |
| 292 | + drawRect(color.copy(alpha = 0.85f), topLeft = Offset((x + 1.2f) * unit, (y + 1.2f) * unit), size = Size(unit + 0.5f, unit + 0.5f)) | |
| 293 | + } | |
| 294 | + } | |
| 295 | + } | |
| 296 | + | |
| 297 | + /** A repository's mark on a raised key: its uploaded image, or the identicon of its owner. */ | |
| 298 | + @Composable | |
| 299 | + fun RepoAvatar(repo: RepoSummary, modifier: Modifier = Modifier, size: Dp = 40.dp) { | |
| 300 | + val shape = RoundedCornerShape(maxOf(4.dp, size * 0.3f)) | |
| 301 | + val api = LocalApi.current | |
| 302 | + Box(modifier.size(size).raised(shape).clip(shape), contentAlignment = Alignment.Center) { | |
| 303 | + if (repo.hasImage && api != null) { | |
| 304 | + AsyncImage( | |
| 305 | + model = ImageRequest.Builder(LocalContext.current).data(api.repoImageUrl(repo.owner, repo.name, repo.imageVersion)).crossfade(true).build(), | |
| 306 | + contentDescription = null, | |
| 307 | + contentScale = ContentScale.Crop, | |
| 308 | + modifier = Modifier.size(size), | |
| 309 | + ) | |
| 310 | + } else { | |
| 311 | + Identicon(repo.owner, Modifier.size(size)) | |
| 312 | + } | |
| 313 | + } | |
| 314 | + } | |
| 315 | + | |
| 316 | + /** A glyph sunk in a well, like the website's empty states and feature cards. */ | |
| 317 | + @Composable | |
| 318 | + fun IconTile(icon: ImageVector, modifier: Modifier = Modifier, size: Dp = 40.dp, shape: Shape = RoundedCornerShape(size * 0.32f), tint: Color = Vectrum.colors.ink3) { | |
| 319 | + Box(modifier.size(size).well(shape), contentAlignment = Alignment.Center) { | |
| 320 | + Icon(icon, null, Modifier.size(size * 0.46f), tint = tint) | |
| 321 | + } | |
| 322 | + } | |
| 323 | + | |
| 324 | + /** Text with a leading glyph, in a row. */ | |
| 325 | + @Composable | |
| 326 | + fun IconText(icon: ImageVector, text: String, modifier: Modifier = Modifier, tint: Color = Vectrum.colors.ink3, style: TextStyle = Vectrum.type.meta, color: Color = Vectrum.colors.ink2) { | |
| 327 | + Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { | |
| 328 | + Icon(icon, null, Modifier.size(14.dp), tint = tint) | |
| 329 | + Text(text, style = style, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis) | |
| 330 | + } | |
| 331 | + } | |
| @@ -0,0 +1,139 @@ | ||
| 1 | + package cc.vectrum.app.ui.components | |
| 2 | + | |
| 3 | + import androidx.compose.animation.animateContentSize | |
| 4 | + import androidx.compose.foundation.background | |
| 5 | + import androidx.compose.foundation.layout.Arrangement | |
| 6 | + import androidx.compose.foundation.layout.Box | |
| 7 | + import androidx.compose.foundation.layout.Column | |
| 8 | + import androidx.compose.foundation.layout.Row | |
| 9 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 10 | + import androidx.compose.foundation.layout.padding | |
| 11 | + import androidx.compose.foundation.layout.size | |
| 12 | + import androidx.compose.foundation.layout.width | |
| 13 | + import androidx.compose.material3.Icon | |
| 14 | + import androidx.compose.material3.Text | |
| 15 | + import androidx.compose.runtime.Composable | |
| 16 | + import androidx.compose.runtime.getValue | |
| 17 | + import androidx.compose.runtime.mutableStateOf | |
| 18 | + import androidx.compose.runtime.remember | |
| 19 | + import androidx.compose.runtime.saveable.rememberSaveable | |
| 20 | + import androidx.compose.runtime.setValue | |
| 21 | + import androidx.compose.ui.Alignment | |
| 22 | + import androidx.compose.ui.Modifier | |
| 23 | + import androidx.compose.ui.graphics.Color | |
| 24 | + import androidx.compose.ui.draw.rotate | |
| 25 | + import androidx.compose.ui.text.font.FontWeight | |
| 26 | + import androidx.compose.ui.text.style.TextAlign | |
| 27 | + import androidx.compose.ui.text.style.TextOverflow | |
| 28 | + import androidx.compose.ui.unit.dp | |
| 29 | + import androidx.compose.ui.unit.sp | |
| 30 | + import cc.vectrum.app.data.DiffLine | |
| 31 | + import cc.vectrum.app.data.FileDiff | |
| 32 | + import cc.vectrum.app.ui.icons.VIcons | |
| 33 | + import cc.vectrum.app.ui.theme.Radii | |
| 34 | + import cc.vectrum.app.ui.theme.Vectrum | |
| 35 | + | |
| 36 | + /** Lines shown before a long file asks to be opened up. */ | |
| 37 | + private const val PREVIEW_LINES = 250 | |
| 38 | + | |
| 39 | + /** A summary bar of a change: five blocks, green for additions and red for deletions, like the website. */ | |
| 40 | + @Composable | |
| 41 | + fun DiffStat(additions: Int, deletions: Int, modifier: Modifier = Modifier) { | |
| 42 | + val c = Vectrum.colors | |
| 43 | + val total = additions + deletions | |
| 44 | + val green = if (total == 0) 0 else Math.round(5f * additions / total).coerceIn(if (additions > 0) 1 else 0, 5) | |
| 45 | + Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 46 | + Text("+$additions", style = Vectrum.type.readout, color = c.greenDeep) | |
| 47 | + Text("−$deletions", style = Vectrum.type.readout, color = c.red) | |
| 48 | + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { | |
| 49 | + repeat(5) { i -> | |
| 50 | + val color = when { | |
| 51 | + total == 0 -> c.wellTop | |
| 52 | + i < green -> c.greenDeep | |
| 53 | + else -> c.red.copy(alpha = 0.85f) | |
| 54 | + } | |
| 55 | + Box(Modifier.size(7.dp).background(color, Radii.key)) | |
| 56 | + } | |
| 57 | + } | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + /** One file of a diff, on its own plate: a header to fold it, then its lines with both line numbers. */ | |
| 62 | + @Composable | |
| 63 | + fun DiffFile(file: FileDiff, modifier: Modifier = Modifier, startExpanded: Boolean = true) { | |
| 64 | + val c = Vectrum.colors | |
| 65 | + var expanded by rememberSaveable(file.path) { mutableStateOf(startExpanded) } | |
| 66 | + var showAll by rememberSaveable(file.path + ":all") { mutableStateOf(false) } | |
| 67 | + Plate(modifier.fillMaxWidth().animateContentSize(), shape = Radii.plate) { | |
| 68 | + PressableRow({ expanded = !expanded }) { | |
| 69 | + Row(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 70 | + Icon(VIcons.ChevronDown, null, Modifier.size(16.dp).rotate(if (expanded) 0f else -90f), tint = c.ink3) | |
| 71 | + Gap(8.dp) | |
| 72 | + Column(Modifier.weight(1f)) { | |
| 73 | + val (dir, name) = file.path.substringBeforeLast('/', "") to file.path.substringAfterLast('/') | |
| 74 | + Text(name, style = Vectrum.type.ui.copy(fontWeight = FontWeight.SemiBold), color = c.ink, maxLines = 1, overflow = TextOverflow.Ellipsis) | |
| 75 | + val sub = when (file.status) { | |
| 76 | + "renamed" -> "renamed from ${file.oldPath}" | |
| 77 | + "added" -> if (dir.isEmpty()) "new file" else "$dir · new file" | |
| 78 | + "deleted" -> if (dir.isEmpty()) "deleted" else "$dir · deleted" | |
| 79 | + else -> dir | |
| 80 | + } | |
| 81 | + if (sub.isNotEmpty()) Text(sub, style = Vectrum.type.meta, color = c.ink3, maxLines = 1, overflow = TextOverflow.Ellipsis) | |
| 82 | + } | |
| 83 | + Gap(8.dp) | |
| 84 | + DiffStat(file.additions, file.deletions) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + if (expanded) { | |
| 88 | + Rule() | |
| 89 | + when { | |
| 90 | + file.binary -> Text("Binary file not shown.", style = Vectrum.type.meta, color = c.ink3, modifier = Modifier.padding(16.dp)) | |
| 91 | + file.lines.isEmpty() -> Text(if (file.status == "renamed") "Renamed without changes." else "No changes to show.", style = Vectrum.type.meta, color = c.ink3, modifier = Modifier.padding(16.dp)) | |
| 92 | + else -> { | |
| 93 | + val shown = if (showAll) file.lines else file.lines.take(PREVIEW_LINES) | |
| 94 | + Column(Modifier.padding(vertical = 6.dp)) { | |
| 95 | + for (line in shown) DiffLineRow(line) | |
| 96 | + } | |
| 97 | + if (file.lines.size > shown.size) { | |
| 98 | + VButton("Show ${file.lines.size - shown.size} More Lines", { showAll = true }, kind = ButtonKind.Ghost, small = true, modifier = Modifier.padding(8.dp)) | |
| 99 | + } | |
| 100 | + if (file.truncated) { | |
| 101 | + Text("This file's diff is too long to show in full.", style = Vectrum.type.meta, color = c.ink3, modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)) | |
| 102 | + } | |
| 103 | + } | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + @Composable | |
| 110 | + private fun DiffLineRow(line: DiffLine) { | |
| 111 | + val c = Vectrum.colors | |
| 112 | + val style = Vectrum.type.code.copy(fontSize = 12.sp, lineHeight = 19.sp) | |
| 113 | + if (line.kind == "hunk") { | |
| 114 | + Text( | |
| 115 | + line.text, | |
| 116 | + style = style, | |
| 117 | + color = c.ink3, | |
| 118 | + maxLines = 2, | |
| 119 | + overflow = TextOverflow.Ellipsis, | |
| 120 | + modifier = Modifier.fillMaxWidth().background(c.diffHunk).padding(horizontal = 14.dp, vertical = 5.dp), | |
| 121 | + ) | |
| 122 | + return | |
| 123 | + } | |
| 124 | + val (bg, gutter, sign) = when (line.kind) { | |
| 125 | + "add" -> Triple(c.diffAdd, c.diffAddGutter, "+") | |
| 126 | + "del" -> Triple(c.diffDel, c.diffDelGutter, "−") | |
| 127 | + else -> Triple(Color.Transparent, Color.Transparent, " ") | |
| 128 | + } | |
| 129 | + val numbers = remember(line) { (line.oldNo?.toString() ?: "") to (line.newNo?.toString() ?: "") } | |
| 130 | + Row(Modifier.fillMaxWidth().background(bg)) { | |
| 131 | + val numberInk = if (line.kind == "ctx") c.syntax.line else c.ink3 | |
| 132 | + Row(Modifier.background(gutter).padding(horizontal = 4.dp)) { | |
| 133 | + Text(numbers.first, style = style, color = numberInk, textAlign = TextAlign.End, modifier = Modifier.width(30.dp), maxLines = 1) | |
| 134 | + Text(numbers.second, style = style, color = numberInk, textAlign = TextAlign.End, modifier = Modifier.width(30.dp), maxLines = 1) | |
| 135 | + } | |
| 136 | + Text(sign, style = style, color = when (line.kind) { "add" -> c.greenDeep; "del" -> c.red; else -> c.ink3 }, modifier = Modifier.padding(start = 8.dp, end = 4.dp)) | |
| 137 | + Text(line.text.replace("\t", " "), style = style, color = c.syntax.text, modifier = Modifier.weight(1f).padding(end = 12.dp)) | |
| 138 | + } | |
| 139 | + } | |
| @@ -0,0 +1,111 @@ | ||
| 1 | + package cc.vectrum.app.ui.components | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Arrangement | |
| 4 | + import androidx.compose.foundation.layout.Box | |
| 5 | + import androidx.compose.foundation.layout.Column | |
| 6 | + import androidx.compose.foundation.layout.Row | |
| 7 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 8 | + import androidx.compose.foundation.layout.heightIn | |
| 9 | + import androidx.compose.foundation.layout.padding | |
| 10 | + import androidx.compose.foundation.layout.size | |
| 11 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 12 | + import androidx.compose.foundation.text.BasicTextField | |
| 13 | + import androidx.compose.foundation.text.KeyboardActions | |
| 14 | + import androidx.compose.foundation.text.KeyboardOptions | |
| 15 | + import androidx.compose.material3.Icon | |
| 16 | + import androidx.compose.material3.Text | |
| 17 | + import androidx.compose.runtime.Composable | |
| 18 | + import androidx.compose.ui.Alignment | |
| 19 | + import androidx.compose.ui.Modifier | |
| 20 | + import androidx.compose.ui.focus.FocusRequester | |
| 21 | + import androidx.compose.ui.focus.focusRequester | |
| 22 | + import androidx.compose.ui.graphics.SolidColor | |
| 23 | + import androidx.compose.ui.graphics.vector.ImageVector | |
| 24 | + import androidx.compose.ui.text.TextStyle | |
| 25 | + import androidx.compose.ui.text.input.PasswordVisualTransformation | |
| 26 | + import androidx.compose.ui.text.input.VisualTransformation | |
| 27 | + import androidx.compose.ui.unit.dp | |
| 28 | + import cc.vectrum.app.ui.icons.VIcons | |
| 29 | + import cc.vectrum.app.ui.theme.Vectrum | |
| 30 | + import cc.vectrum.app.ui.theme.well | |
| 31 | + | |
| 32 | + /** | |
| 33 | + * A text field on MetalUI's well: sunk into the surface, focus shown by its green caret rather than | |
| 34 | + * a ring (as on the website). | |
| 35 | + */ | |
| 36 | + @Composable | |
| 37 | + fun Field( | |
| 38 | + value: String, | |
| 39 | + onValueChange: (String) -> Unit, | |
| 40 | + modifier: Modifier = Modifier, | |
| 41 | + label: String? = null, | |
| 42 | + placeholder: String = "", | |
| 43 | + leading: ImageVector? = null, | |
| 44 | + trailing: (@Composable () -> Unit)? = null, | |
| 45 | + password: Boolean = false, | |
| 46 | + singleLine: Boolean = true, | |
| 47 | + minLines: Int = 1, | |
| 48 | + enabled: Boolean = true, | |
| 49 | + keyboard: KeyboardOptions = KeyboardOptions.Default, | |
| 50 | + actions: KeyboardActions = KeyboardActions.Default, | |
| 51 | + style: TextStyle = Vectrum.type.lead, | |
| 52 | + hint: String? = null, | |
| 53 | + focusRequester: FocusRequester? = null, | |
| 54 | + ) { | |
| 55 | + val c = Vectrum.colors | |
| 56 | + Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 57 | + if (label != null) Engraved(label, Modifier.padding(start = 4.dp)) | |
| 58 | + val shape = RoundedCornerShape(if (singleLine) 16.dp else 18.dp) | |
| 59 | + BasicTextField( | |
| 60 | + value = value, | |
| 61 | + onValueChange = onValueChange, | |
| 62 | + enabled = enabled, | |
| 63 | + singleLine = singleLine, | |
| 64 | + minLines = minLines, | |
| 65 | + textStyle = style.copy(color = c.ink), | |
| 66 | + cursorBrush = SolidColor(c.greenDeep), | |
| 67 | + visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None, | |
| 68 | + keyboardOptions = keyboard, | |
| 69 | + keyboardActions = actions, | |
| 70 | + modifier = Modifier.fillMaxWidth().let { m -> if (focusRequester != null) m.focusRequester(focusRequester) else m }, | |
| 71 | + decorationBox = { inner -> | |
| 72 | + Row( | |
| 73 | + Modifier | |
| 74 | + .fillMaxWidth() | |
| 75 | + .well(shape) | |
| 76 | + .heightIn(min = 50.dp) | |
| 77 | + .padding(horizontal = 16.dp, vertical = if (singleLine) 0.dp else 14.dp), | |
| 78 | + verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top, | |
| 79 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 80 | + ) { | |
| 81 | + if (leading != null) Icon(leading, null, Modifier.size(18.dp), tint = c.ink3) | |
| 82 | + Box(Modifier.weight(1f).padding(vertical = if (singleLine) 14.dp else 0.dp)) { | |
| 83 | + if (value.isEmpty()) Text(placeholder, style = style, color = c.ink3.copy(alpha = 0.8f), maxLines = if (singleLine) 1 else Int.MAX_VALUE) | |
| 84 | + inner() | |
| 85 | + } | |
| 86 | + trailing?.invoke() | |
| 87 | + } | |
| 88 | + }, | |
| 89 | + ) | |
| 90 | + if (hint != null) Text(hint, style = Vectrum.type.meta, color = c.ink3, modifier = Modifier.padding(horizontal = 4.dp)) | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** A search field: the website's "Search…" well. */ | |
| 95 | + @Composable | |
| 96 | + fun SearchField(value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, placeholder: String = "Search") { | |
| 97 | + Field( | |
| 98 | + value = value, | |
| 99 | + onValueChange = onValueChange, | |
| 100 | + modifier = modifier, | |
| 101 | + placeholder = placeholder, | |
| 102 | + leading = VIcons.Search, | |
| 103 | + style = Vectrum.type.ui.copy(fontSize = Vectrum.type.lead.fontSize), | |
| 104 | + keyboard = KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search), | |
| 105 | + trailing = if (value.isNotEmpty()) { | |
| 106 | + { RoundButton(VIcons.Close, "Clear", { onValueChange("") }, size = 30.dp, ghost = true, tint = Vectrum.colors.ink3) } | |
| 107 | + } else { | |
| 108 | + null | |
| 109 | + }, | |
| 110 | + ) | |
| 111 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Binary file not shown.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
File mode or name changed only.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
File mode or name changed only.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.