Merge pull request 'feat(m1): Connect & browse — first-run flow, public content, contact' (#6) from feat/m1-connect-browse into main

Reviewed-on: #6
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-19 23:10:46 +00:00
57 changed files with 3285 additions and 54 deletions

View File

@@ -65,7 +65,11 @@ jobs:
# chmod defensively: this runner's checkout doesn't preserve the git
# executable bit, so `./gradlew` alone fails with "Permission denied".
# Debug-variant-only gate: unit tests, lint, and the debug APK. Scoping to
# the debug variant (vs. the aggregate `test`/`lint`) avoids compiling and
# linting the release variant in parallel, which halves peak memory on the
# runner and keeps lint's report phase from GC-thrashing (see gradle.properties).
- name: Lint, test, assemble debug
run: |
chmod +x ./gradlew
./gradlew --no-daemon lint test assembleDebug
./gradlew --no-daemon testDebugUnitTest lintDebug assembleDebug

View File

@@ -70,6 +70,7 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.navigation.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)

View File

@@ -7,24 +7,29 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.ui.AppViewModel
import com.runicgateway.app.ui.AppViewModel.AppState
import com.runicgateway.app.ui.LocalAssetResolver
import com.runicgateway.app.ui.RunicApp
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.connect.ConnectScreen
import com.runicgateway.app.ui.theme.RunicGatewayTheme
import com.runicgateway.app.ui.theme.parseBrandColor
import dagger.hilt.android.AndroidEntryPoint
/**
* Single-activity host. Navigation-Compose and the first-run base-URL flow (§3)
* land in M1; this M0 skeleton only proves the Compose + Hilt + theme wiring.
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
* connect screen until a shard site is configured (§3), then the main app.
* The Material theme is seeded from the per-shard brand accent, and asset-path
* resolution is provided to the whole tree.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -32,33 +37,27 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
RunicGatewayTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Placeholder(modifier = Modifier.padding(innerPadding))
}
}
}
}
}
val appViewModel: AppViewModel = hiltViewModel()
val state by appViewModel.state.collectAsStateWithLifecycle()
@Composable
private fun Placeholder(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
val accent = (state as? AppState.Ready)?.brand?.let { parseBrandColor(it.accent) }
RunicGatewayTheme(accent = accent) {
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Text(
text = stringResource(id = R.string.app_scaffold_ready),
style = MaterialTheme.typography.titleLarge,
)
}
}
@Preview(showBackground = true)
@Composable
private fun PlaceholderPreview() {
RunicGatewayTheme {
Placeholder()
when (val s = state) {
AppState.Loading -> LoadingView()
AppState.NeedsConnection ->
ConnectScreen(onConnected = appViewModel::onConnected)
is AppState.Ready ->
RunicApp(brand = s.brand, onChangeServer = appViewModel::changeServer)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,14 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core
/**
* Build-derived flags, injected rather than read from `BuildConfig` directly so
* the logic that consumes them (URL validation, etc.) stays plain and unit-testable.
*/
data class AppConfig(
/** Allow plain HTTP base URLs. Debug-only (local dev); release requires HTTPS (§3). */
val allowInsecureHttp: Boolean,
val versionName: String,
)

View File

@@ -0,0 +1,40 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import okhttp3.HttpUrl
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton
/**
* Holds the currently-selected shard website base URL (PLAN.md §3). The API host
* is not compiled in: it is chosen on first run, may be changed later under
* Settings → Server, and every outbound API request is retargeted onto it by
* [HostSelectionInterceptor].
*
* Threading: the value is read on every network call and written from the
* connect/settings flows, so it lives in an [AtomicReference].
*/
@Singleton
class BaseUrlHolder @Inject constructor() {
private val ref = AtomicReference<HttpUrl?>(null)
/** The configured base, or null before first-run connect completes. */
val current: HttpUrl? get() = ref.get()
fun set(url: HttpUrl?) = ref.set(url)
companion object {
/**
* Sentinel host used as Retrofit's compile-time `baseUrl`. Relative
* endpoint paths resolve against it; [HostSelectionInterceptor] rewrites
* exactly these requests onto [current]. Absolute-URL probe requests use
* a real host and are left untouched. `.invalid` is reserved (RFC 6761)
* so it can never accidentally resolve on a network.
*/
const val PLACEHOLDER_HOST = "runic-gateway.invalid"
const val PLACEHOLDER_BASE_URL = "https://$PLACEHOLDER_HOST/"
}
}

View File

@@ -0,0 +1,53 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
/**
* Retargets each relative API request onto the runtime-selected base URL
* (PLAN.md §3). Retrofit is built with a sentinel base host
* ([BaseUrlHolder.PLACEHOLDER_HOST]); this interceptor swaps the scheme/host/port
* for the configured shard site and prefixes any base path the user included
* (`https://host/base`). Absolute-URL requests (the connect probe via `@Url`)
* carry a real host and pass through untouched.
*/
@Singleton
class HostSelectionInterceptor @Inject constructor(
private val baseUrlHolder: BaseUrlHolder,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.url.host != BaseUrlHolder.PLACEHOLDER_HOST) {
return chain.proceed(request)
}
val base = baseUrlHolder.current
?: throw IOException("No shard website is configured yet.")
val rewritten = rewriteOntoBase(base, request.url)
return chain.proceed(request.newBuilder().url(rewritten).build())
}
}
/**
* Resolve the sentinel-hosted [requestUrl] against [base] the way a browser
* resolves a relative link. [base] is guaranteed to end in "/"
* (ServerUrl.normalize), so `https://host/base/` + `api/v1/x` yields
* `https://host/base/api/v1/x` — the base path prefix is preserved and no double
* slash appears. Extracted as a pure function for unit testing.
*/
fun rewriteOntoBase(base: HttpUrl, requestUrl: HttpUrl): HttpUrl {
val relative = buildString {
append(requestUrl.encodedPath.removePrefix("/"))
requestUrl.encodedQuery?.let { append('?').append(it) }
}
return base.resolve(relative) ?: base
}

View File

@@ -0,0 +1,78 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* Parses and normalizes the base URL a user types on the first-run "Connect to
* your shard's website" screen (PLAN.md §3). Pure, dependency-light logic so it
* is exercised directly in JVM unit tests.
*
* Rules:
* - accept `https://host[/base]`; a bare `host[/base]` gets an implicit scheme
* (`https` normally, so users needn't type it);
* - trim surrounding whitespace;
* - require HTTPS unless [allowInsecureHttp] (release builds forbid HTTP; debug
* allows it for local dev against `127.0.0.1:3000`);
* - drop any query/fragment and guarantee a trailing slash on the path so the
* stored value composes cleanly with relative endpoint paths.
*/
object ServerUrl {
sealed interface Result {
data class Valid(val url: HttpUrl) : Result
data class Invalid(val reason: Reason) : Result
}
enum class Reason {
/** Empty or whitespace-only input. */
BLANK,
/** Not a parseable http(s) URL (bad host, illegal characters, …). */
MALFORMED,
/** A scheme other than http/https (e.g. ftp://, ws://). */
UNSUPPORTED_SCHEME,
/** Plain HTTP where the build requires HTTPS. */
INSECURE,
}
fun normalize(raw: String, allowInsecureHttp: Boolean): Result {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return Result.Invalid(Reason.BLANK)
// Give a scheme-less entry an implicit, secure default so users can type
// just "shard.example.com". An explicit but unsupported scheme is rejected.
val hasScheme = SCHEME_RE.containsMatchIn(trimmed)
val candidate = if (hasScheme) trimmed else "https://$trimmed"
val lowerScheme = candidate.substringBefore("://", "").lowercase()
if (hasScheme && lowerScheme != "http" && lowerScheme != "https") {
return Result.Invalid(Reason.UNSUPPORTED_SCHEME)
}
val parsed = candidate.toHttpUrlOrNull() ?: return Result.Invalid(Reason.MALFORMED)
if (parsed.host.isBlank()) return Result.Invalid(Reason.MALFORMED)
if (parsed.scheme == "http" && !allowInsecureHttp) {
return Result.Invalid(Reason.INSECURE)
}
// Rebuild without query/fragment and force a trailing slash so
// HttpUrl.resolve()/addPathSegments compose predictably later.
val path = parsed.encodedPath.trimEnd('/')
val normalized = parsed.newBuilder()
.encodedPath(if (path.isEmpty()) "/" else "$path/")
.query(null)
.fragment(null)
.build()
return Result.Valid(normalized)
}
private val SCHEME_RE = Regex("^[a-zA-Z][a-zA-Z0-9+.-]*://")
}

View File

@@ -0,0 +1,24 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import okhttp3.Interceptor
import okhttp3.Response
/**
* Sets a stable, identifiable User-Agent on every request. The website mounts a
* bot/scanner guard ahead of routing (PLAN.md §8); a native client must present
* a sane UA so it is not caught by the scanner heuristics that reject blank or
* default agents. Constructed with the app's UA string in the network module.
*/
class UserAgentInterceptor(
private val userAgent: String,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("User-Agent", userAgent)
.build()
return chain.proceed(request)
}
}

View File

@@ -0,0 +1,48 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.serverDataStore: DataStore<Preferences> by preferencesDataStore(name = "server")
/**
* Persists the selected shard website base URL (PLAN.md §3). The base URL is
* non-sensitive, so it lives in plain DataStore; tokens (M3) will use
* EncryptedSharedPreferences instead, never this store.
*/
@Singleton
class ServerPreferences @Inject constructor(
@param:dagger.hilt.android.qualifiers.ApplicationContext private val context: Context,
) {
private val store = context.serverDataStore
/** Emits the saved base URL, or null before first-run connect completes. */
val baseUrl: Flow<String?> = store.data.map { it[KEY_BASE_URL] }
suspend fun currentBaseUrl(): String? = baseUrl.first()
suspend fun setBaseUrl(url: String) {
store.edit { it[KEY_BASE_URL] = url }
}
/** Clears the base URL — used by a Settings → Server switch (hard reset, §3). */
suspend fun clear() {
store.edit { it.remove(KEY_BASE_URL) }
}
private companion object {
val KEY_BASE_URL = stringPreferencesKey("base_url")
}
}

View File

@@ -0,0 +1,49 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.result
import kotlinx.coroutines.CancellationException
import retrofit2.HttpException
import java.io.IOException
/**
* The single result type every repository call returns (PLAN.md §7). The UI
* degrades gracefully on a down backend or shard: a repository never throws for
* an expected failure, it returns a typed variant the screen can render.
*
* - [Ok] — a 2xx response with a decoded body.
* - [HttpError] — the server answered with a non-2xx status.
* - [NetworkError] — the request never got an answer (offline, DNS, TLS, timeout).
*/
sealed interface ApiResult<out T> {
data class Ok<T>(val data: T) : ApiResult<T>
data class HttpError(val status: Int, val message: String? = null) : ApiResult<Nothing>
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
}
/** True for the "shard/sidecar down" signal the player screens treat as offline (§6.3, §7). */
fun ApiResult<*>.isShardUnavailable(): Boolean =
this is ApiResult.HttpError && status == 503
/** Map an [ApiResult.Ok] body while preserving the failure variants unchanged. */
inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (this) {
is ApiResult.Ok -> ApiResult.Ok(transform(data))
is ApiResult.HttpError -> this
is ApiResult.NetworkError -> this
}
/**
* Run a suspending Retrofit call and normalize every outcome into an [ApiResult].
* Coroutine cancellation is rethrown so structured concurrency still works — it
* is control flow, not a network failure.
*/
suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.Ok(block())
} catch (e: CancellationException) {
throw e
} catch (e: HttpException) {
ApiResult.HttpError(e.code(), e.message())
} catch (e: IOException) {
ApiResult.NetworkError(e)
}

View File

@@ -0,0 +1,82 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.ContactRequest
import com.runicgateway.app.data.api.dto.ContactResponse
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
import com.runicgateway.app.data.api.dto.WikiTagDto
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Url
/**
* The public (unauthenticated) surface consumed in M1: site status/settings,
* news posts, CMS pages, wiki, and the contact form (PLAN.md §6.1). Paths are
* relative to the sentinel base host; [com.runicgateway.app.core.net.HostSelectionInterceptor]
* retargets them onto the configured shard site. Auth (§4) and the shard widgets
* (§6.2) arrive in later milestones.
*/
interface PublicApi {
// ── First-run probe (absolute URL; bypasses host rewriting) ──────────
/**
* Validates a candidate site during the first-run connect flow (§3). Takes a
* fully-qualified URL so the request carries a real host and is left
* untouched by the host interceptor — the probe targets the URL the user
* just typed, not the (not-yet-configured) base.
*/
@GET
suspend fun probeStatus(@Url absoluteStatusUrl: String): StatusDto
// ── Site status / settings ───────────────────────────────────────────
@GET("api/v1/public/status")
suspend fun getStatus(): StatusDto
@GET("api/v1/public/settings")
suspend fun getSettings(): SettingsDto
// ── News & content ───────────────────────────────────────────────────
@GET("api/v1/public/posts/{category}")
suspend fun getPosts(@Path("category") category: String): List<PostDto>
@GET("api/v1/public/posts/{category}/{idOrSlug}")
suspend fun getPost(
@Path("category") category: String,
@Path("idOrSlug") idOrSlug: String,
): PostDto
@GET("api/v1/public/pages/{slug}")
suspend fun getPage(@Path("slug") slug: String): PageDto
// ── Wiki ─────────────────────────────────────────────────────────────
@GET("api/v1/public/wiki")
suspend fun getWikiPages(
@Query("q") query: String? = null,
@Query("category") category: String? = null,
@Query("tag") tag: String? = null,
): List<WikiSummaryDto>
@GET("api/v1/public/wiki/categories")
suspend fun getWikiCategories(): List<WikiCategoryDto>
@GET("api/v1/public/wiki/tags")
suspend fun getWikiTags(): List<WikiTagDto>
@GET("api/v1/public/wiki/{slug}")
suspend fun getWikiPage(@Path("slug") slug: String): WikiPageDto
// ── Contact ──────────────────────────────────────────────────────────
@POST("api/v1/public/contact")
suspend fun postContact(@Body body: ContactRequest): ContactResponse
}

View File

@@ -0,0 +1,26 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/** `POST /public/contact` request body. */
@Serializable
data class ContactRequest(
val name: String,
val email: String,
val message: String,
)
/**
* `POST /public/contact` response (website `mailer.sendContactMessage`). Either
* `{ sent: true }`, or `{ sent: false, fallback: "mailto", email }` when the
* site has no mailer configured and the user should email directly instead.
*/
@Serializable
data class ContactResponse(
val sent: Boolean = false,
val fallback: String? = null,
val email: String? = null,
)

View File

@@ -0,0 +1,38 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/**
* `GET /public/pages/:slug` — a block-based CMS page (website `pages.model.js`
* `serialize`). Everything block-specific lives inside `props`; the renderer
* dispatches on `type`. `props` is left as a raw JSON object so new block types
* or props never break decoding — the renderer reads the keys it knows and
* ignores the rest.
*/
@Serializable
data class PageDto(
val id: Long,
val slug: String = "",
val title: String = "",
val status: String = "",
val blocks: List<BlockDto> = emptyList(),
@SerialName("publishedAt") val publishedAt: String? = null,
@SerialName("updatedAt") val updatedAt: String? = null,
)
/**
* One CMS block. Known types (website `src/blocks/types`): `heading`,
* `rich_text`, `image`, `quote`, `cta`, `divider`, `two_column`. Container
* blocks (`two_column`) hold sub-block arrays inside their props.
*/
@Serializable
data class BlockDto(
val type: String = "",
val props: JsonObject = JsonObject(emptyMap()),
val visible: Boolean = true,
)

View File

@@ -0,0 +1,26 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* `GET /public/posts/:category` (list) and `/public/posts/:category/:idOrSlug`
* (detail). One shape serves both; the list omits nothing the app renders. The
* `body` (HTML) is present on the detail response and rendered there.
*/
@Serializable
data class PostDto(
val id: Long,
/** Stored DB category (`news | five_on_friday | newsletter | screenshot`). */
val category: String = "",
val title: String = "",
val slug: String? = null,
val excerpt: String? = null,
val body: String? = null,
@SerialName("image_url") val imageUrl: String? = null,
@SerialName("published_at") val publishedAt: String? = null,
@SerialName("created_at") val createdAt: String? = null,
)

View File

@@ -0,0 +1,70 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* DTOs for the public site/identity endpoints. Shapes mirror the backend
* responses (website `public.controller.js` + `settings.model.js`); unknown
* keys are ignored by the JSON parser so additive backend fields never break
* decoding (PLAN.md §8 "additive, v1").
*/
/** `GET /public/version` and the `version` block embedded in `/public/status`. */
@Serializable
data class VersionDto(
val service: String = "",
val api: String = "",
val server: String = "",
)
/** `GET /public/status` — site mode + version for the first-run probe (§3). */
@Serializable
data class StatusDto(
val mode: String = "live",
@SerialName("status_message") val statusMessage: String = "",
val version: VersionDto = VersionDto(),
) {
val isMaintenance: Boolean get() = mode.equals("maintenance", ignoreCase = true)
}
/** Per-shard branding block the app themes itself from (§3, §6.1). */
@Serializable
data class BrandDto(
val name: String = "",
val shortName: String = "",
val tagline: String = "",
val description: String? = null,
val contactEmail: String = "",
val url: String = "",
/** Seed/accent color as a hex string, e.g. "#7f99bd". */
val accent: String = "",
/** Asset URL or site-relative path; empty = none. Resolve against the base URL. */
val logo: String = "",
val hero: String = "",
val favicon: String = "",
)
/** Derived, public-safe registration availability flags. */
@Serializable
data class RegistrationFlagsDto(
val password: Boolean = false,
val sso: Boolean = false,
)
/**
* `GET /public/settings` — whitelisted settings + branding. Only the keys the
* app consumes are modeled; other whitelisted keys are ignored.
*/
@Serializable
data class SettingsDto(
@SerialName("site_title") val siteTitle: String? = null,
@SerialName("status_message") val statusMessage: String? = null,
@SerialName("maintenance_message") val maintenanceMessage: String? = null,
val registration: RegistrationFlagsDto = RegistrationFlagsDto(),
val gameAccountSignup: Boolean = false,
val brand: BrandDto = BrandDto(),
)

View File

@@ -0,0 +1,76 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wiki DTOs (website `wiki.model.js` / `wiki.db.js`). The list route returns
* lighter summary rows (no body); the detail route returns the full page plus
* its tags, backlinks, and unresolved ("red") link targets.
*/
/** `GET /public/wiki` — a summary row (body omitted). */
@Serializable
data class WikiSummaryDto(
val id: Long,
val slug: String = "",
val title: String = "",
val excerpt: String? = null,
@SerialName("category_slug") val categorySlug: String? = null,
@SerialName("category_title") val categoryTitle: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
@SerialName("published_at") val publishedAt: String? = null,
)
/** `GET /public/wiki/:slug` — full page + tags/backlinks. */
@Serializable
data class WikiPageDto(
val id: Long,
val slug: String = "",
val title: String = "",
val body: String? = null,
val excerpt: String? = null,
@SerialName("category_slug") val categorySlug: String? = null,
@SerialName("category_title") val categoryTitle: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
@SerialName("published_at") val publishedAt: String? = null,
val tags: List<WikiTagRefDto> = emptyList(),
val backlinks: List<WikiBacklinkDto> = emptyList(),
@SerialName("missing_links") val missingLinks: List<String> = emptyList(),
)
/** A tag as attached to a page (slug + label only). */
@Serializable
data class WikiTagRefDto(
val slug: String = "",
val label: String = "",
)
/** A page that links to the current page. */
@Serializable
data class WikiBacklinkDto(
val slug: String = "",
val title: String = "",
)
/** `GET /public/wiki/categories`. */
@Serializable
data class WikiCategoryDto(
val id: Long,
val slug: String = "",
val title: String = "",
val description: String? = null,
@SerialName("published_count") val publishedCount: Long = 0,
)
/** `GET /public/wiki/tags`. */
@Serializable
data class WikiTagDto(
val id: Long,
val slug: String = "",
val label: String = "",
@SerialName("published_count") val publishedCount: Long = 0,
)

View File

@@ -0,0 +1,95 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.ServerUrl
import com.runicgateway.app.core.prefs.ServerPreferences
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.dto.StatusDto
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* Owns the shard website base-URL lifecycle (PLAN.md §3): restoring a saved URL
* on launch, validating + persisting a candidate on the first-run connect
* screen, and the hard reset performed by a Settings → Server switch.
*/
@Singleton
class ConnectionRepository @Inject constructor(
private val api: PublicApi,
private val prefs: ServerPreferences,
private val baseUrlHolder: BaseUrlHolder,
private val config: com.runicgateway.app.core.AppConfig,
) {
/** Outcome of validating a candidate base URL against a live site. */
sealed interface ProbeResult {
data class Success(val status: StatusDto) : ProbeResult
data class InvalidUrl(val reason: ServerUrl.Reason) : ProbeResult
/** Reachable and 2xx, but not a Runic Gateway backend (wrong version identity). */
data object NotRunicGateway : ProbeResult
data class ServerError(val status: Int) : ProbeResult
data class Unreachable(val cause: Throwable) : ProbeResult
}
/** True once a saved base URL has been loaded into the holder. */
val isConnected: Boolean get() = baseUrlHolder.current != null
/**
* Restore any saved base URL into the holder on launch. Returns true if the
* app already has a configured shard site (skip the connect screen).
*/
suspend fun restore(): Boolean {
val saved = prefs.currentBaseUrl()?.toHttpUrlOrNull()
baseUrlHolder.set(saved)
return saved != null
}
/**
* Validate [rawUrl], and on success persist it and activate it for all
* subsequent API calls. Insecure HTTP is allowed only in debug builds
* (local dev against 127.0.0.1); release builds require HTTPS.
*/
suspend fun probeAndConnect(rawUrl: String): ProbeResult {
val normalized = when (val r = ServerUrl.normalize(rawUrl, allowInsecureHttp = config.allowInsecureHttp)) {
is ServerUrl.Result.Invalid -> return ProbeResult.InvalidUrl(r.reason)
is ServerUrl.Result.Valid -> r.url
}
val statusUrl = normalized.resolve("api/v1/public/status")?.toString()
?: return ProbeResult.InvalidUrl(ServerUrl.Reason.MALFORMED)
return when (val result = safeApiCall { api.probeStatus(statusUrl) }) {
is ApiResult.Ok -> {
if (!result.data.version.service.equals(RUNIC_SERVICE_ID, ignoreCase = true)) {
ProbeResult.NotRunicGateway
} else {
prefs.setBaseUrl(normalized.toString())
baseUrlHolder.set(normalized)
ProbeResult.Success(result.data)
}
}
is ApiResult.HttpError -> ProbeResult.ServerError(result.status)
is ApiResult.NetworkError -> ProbeResult.Unreachable(result.cause)
}
}
/**
* Hard reset for a Settings → Server switch (§3): clear the saved URL and
* deactivate it. Token/cache clearing joins here in M3 once sessions exist.
*/
suspend fun disconnect() {
prefs.clear()
baseUrlHolder.set(null)
}
private companion object {
const val RUNIC_SERVICE_ID = "runic-gateway"
}
}

View File

@@ -0,0 +1,24 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.dto.ContactRequest
import com.runicgateway.app.data.api.dto.ContactResponse
import javax.inject.Inject
import javax.inject.Singleton
/**
* Contact form submission (PLAN.md §6.1). The endpoint is rate-limited; the
* caller handles `429` (too many) and `502` (mailer down) via [ApiResult.HttpError].
*/
@Singleton
class ContactRepository @Inject constructor(
private val api: PublicApi,
) {
suspend fun send(name: String, email: String, message: String): ApiResult<ContactResponse> =
safeApiCall { api.postContact(ContactRequest(name = name, email = email, message = message)) }
}

View File

@@ -0,0 +1,43 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import javax.inject.Inject
import javax.inject.Singleton
/**
* News posts and CMS pages (PLAN.md §6.1). URL post categories map 1:1 to the
* backend's route segments (`news | five-on-friday | newsletter | screenshots`).
*/
@Singleton
class ContentRepository @Inject constructor(
private val api: PublicApi,
) {
/** Known URL categories, in display order. */
enum class PostCategory(val urlSlug: String) {
NEWS("news"),
FIVE_ON_FRIDAY("five-on-friday"),
NEWSLETTER("newsletter"),
SCREENSHOTS("screenshots"),
;
companion object {
fun fromUrlSlug(slug: String?): PostCategory? = entries.firstOrNull { it.urlSlug == slug }
}
}
suspend fun getPosts(category: PostCategory): ApiResult<List<PostDto>> =
safeApiCall { api.getPosts(category.urlSlug) }
suspend fun getPost(category: PostCategory, idOrSlug: String): ApiResult<PostDto> =
safeApiCall { api.getPost(category.urlSlug, idOrSlug) }
suspend fun getPage(slug: String): ApiResult<PageDto> =
safeApiCall { api.getPage(slug) }
}

View File

@@ -0,0 +1,22 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.StatusDto
import javax.inject.Inject
import javax.inject.Singleton
/** Site status (mode/maintenance) and settings/branding (PLAN.md §6.1). */
@Singleton
class SettingsRepository @Inject constructor(
private val api: PublicApi,
) {
suspend fun getStatus(): ApiResult<StatusDto> = safeApiCall { api.getStatus() }
suspend fun getSettings(): ApiResult<SettingsDto> = safeApiCall { api.getSettings() }
}

View File

@@ -0,0 +1,37 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.dto.WikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
import com.runicgateway.app.data.api.dto.WikiTagDto
import javax.inject.Inject
import javax.inject.Singleton
/** Wiki listing/filtering, categories, tags, and detail (PLAN.md §6.1). */
@Singleton
class WikiRepository @Inject constructor(
private val api: PublicApi,
) {
/** Full-text [query] takes precedence over [category]/[tag] on the backend. */
suspend fun getPages(
query: String? = null,
category: String? = null,
tag: String? = null,
): ApiResult<List<WikiSummaryDto>> =
safeApiCall { api.getWikiPages(query?.takeIf { it.isNotBlank() }, category, tag) }
suspend fun getCategories(): ApiResult<List<WikiCategoryDto>> =
safeApiCall { api.getWikiCategories() }
suspend fun getTags(): ApiResult<List<WikiTagDto>> =
safeApiCall { api.getWikiTags() }
suspend fun getPage(slug: String): ApiResult<WikiPageDto> =
safeApiCall { api.getWikiPage(slug) }
}

View File

@@ -0,0 +1,24 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.di
import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.AppConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideAppConfig(): AppConfig = AppConfig(
allowInsecureHttp = BuildConfig.DEBUG,
versionName = BuildConfig.VERSION_NAME,
)
}

View File

@@ -0,0 +1,78 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.di
import android.os.Build
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.HostSelectionInterceptor
import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.PublicApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideJson(): Json = Json {
// Additive backend fields (§8) must never break decoding; be lenient.
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Provides
@Singleton
fun provideUserAgentInterceptor(): UserAgentInterceptor {
// Identifiable UA so the site's scanner guard (§8) doesn't reject the app.
val ua = "RunicGatewayApp/${BuildConfig.VERSION_NAME} (Android ${Build.VERSION.RELEASE})"
return UserAgentInterceptor(ua)
}
@Provides
@Singleton
fun provideOkHttpClient(
hostSelectionInterceptor: HostSelectionInterceptor,
userAgentInterceptor: UserAgentInterceptor,
): OkHttpClient {
val builder = OkHttpClient.Builder()
// User-Agent first, then host retargeting, so both apply to every call.
.addInterceptor(userAgentInterceptor)
.addInterceptor(hostSelectionInterceptor)
if (BuildConfig.DEBUG) {
builder.addInterceptor(
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC },
)
}
return builder.build()
}
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient, json: Json): Retrofit =
Retrofit.Builder()
// Compile-time sentinel; every relative call is rewritten onto the
// runtime-selected shard site by HostSelectionInterceptor (§3).
.baseUrl(BaseUrlHolder.PLACEHOLDER_BASE_URL)
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
}

View File

@@ -0,0 +1,83 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.repository.ConnectionRepository
import com.runicgateway.app.data.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Top-level app gate (PLAN.md §3): decides whether the first-run connect screen
* or the main UI shows, and holds the per-shard branding the theme is seeded
* from. Activity-scoped so the whole app observes one state.
*/
@HiltViewModel
class AppViewModel @Inject constructor(
private val connectionRepository: ConnectionRepository,
private val settingsRepository: SettingsRepository,
private val baseUrlHolder: BaseUrlHolder,
) : ViewModel() {
sealed interface AppState {
/** Restoring the saved base URL / loading branding. */
data object Loading : AppState
/** No shard site configured yet — show the connect screen. */
data object NeedsConnection : AppState
/** A site is configured; [brand] is null if branding couldn't be loaded (still usable). */
data class Ready(val brand: BrandDto?) : AppState
}
private val _state = MutableStateFlow<AppState>(AppState.Loading)
val state: StateFlow<AppState> = _state.asStateFlow()
init {
viewModelScope.launch {
_state.value = if (connectionRepository.restore()) {
AppState.Ready(loadBrand())
} else {
AppState.NeedsConnection
}
}
}
/** Called by the connect screen once a site has been validated + saved. */
fun onConnected() {
viewModelScope.launch { _state.value = AppState.Ready(loadBrand()) }
}
/** Settings → Server switch: hard reset back to the connect screen (§3). */
fun changeServer() {
viewModelScope.launch {
connectionRepository.disconnect()
_state.value = AppState.NeedsConnection
}
}
private suspend fun loadBrand(): BrandDto? =
(settingsRepository.getSettings() as? ApiResult.Ok)?.data?.brand
/**
* Resolve a possibly site-relative asset path (branding logos, post images)
* to an absolute URL against the configured base (§8: asset fields may be
* site-relative). Absolute URLs pass through unchanged; null/blank stays null.
*/
fun resolveAsset(path: String?): String? {
if (path.isNullOrBlank()) return null
val base = baseUrlHolder.current ?: return path
return base.resolve(path)?.toString() ?: path
}
}

View File

@@ -0,0 +1,18 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui
import androidx.compose.runtime.staticCompositionLocalOf
/**
* Resolves possibly site-relative asset paths (post images, branding) to
* absolute URLs against the configured base URL. Provided at the app root from
* [AppViewModel.resolveAsset] so any screen or CMS block can turn a stored path
* into a loadable URL without reaching into the network layer.
*/
val LocalAssetResolver = staticCompositionLocalOf<(String?) -> String?> {
// Default: identity. Overridden at the app root; this fallback keeps previews
// and tests from crashing if the provider is missing.
{ it }
}

View File

@@ -0,0 +1,216 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.home.HomeScreen
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.news.NewsScreen
import com.runicgateway.app.ui.news.PostScreen
import com.runicgateway.app.ui.page.PageScreen
import com.runicgateway.app.ui.wiki.WikiPageScreen
import com.runicgateway.app.ui.wiki.WikiScreen
import kotlinx.coroutines.launch
/** A navigation menu entry (PLAN.md §5). For M1 every entry is public. */
private data class MenuEntry(val route: String, val labelRes: Int)
private val PUBLIC_MENU = listOf(
MenuEntry(Routes.HOME, R.string.menu_home),
MenuEntry(Routes.NEWS, R.string.menu_news),
MenuEntry(Routes.WIKI, R.string.menu_wiki),
MenuEntry(Routes.page("about"), R.string.menu_about),
MenuEntry(Routes.CONTACT, R.string.menu_contact),
)
/** Destinations that show the drawer (hamburger); others show a back arrow. */
private val TOP_LEVEL_ROUTES = setOf(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.CONTACT, Routes.PAGE,
)
/**
* The main app shell once a shard site is configured (PLAN.md §5): one shared,
* declarative navigation drawer over the public content graph, plus the
* Settings → Server switch. The signed-in menu groups and auth toggle join in M3.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RunicApp(
brand: BrandDto?,
onChangeServer: () -> Unit,
modifier: Modifier = Modifier,
) {
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = isTopLevel,
drawerContent = {
ModalDrawerSheet {
Spacer(Modifier.height(12.dp))
Text(
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
style = androidx.compose.material3.MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
)
HorizontalDivider()
Spacer(Modifier.height(8.dp))
PUBLIC_MENU.forEach { entry ->
NavigationDrawerItem(
label = { Text(stringResource(entry.labelRes)) },
selected = currentRoute == entry.route,
onClick = {
scope.launch { drawerState.close() }
navController.navigateTopLevel(entry.route)
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
NavigationDrawerItem(
label = { Text(stringResource(R.string.menu_change_server)) },
selected = false,
onClick = {
scope.launch { drawerState.close() }
onChangeServer()
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
}
},
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
brand?.name?.takeIf { it.isNotBlank() }
?: stringResource(R.string.app_name),
)
},
navigationIcon = {
if (isTopLevel) {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, stringResource(R.string.nav_open_menu))
}
} else {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
stringResource(R.string.action_back),
)
}
}
},
)
},
) { innerPadding ->
RunicNavHost(
navController = navController,
brand = brand,
modifier = Modifier.padding(innerPadding),
)
}
}
}
@Composable
private fun RunicNavHost(
navController: NavHostController,
brand: BrandDto?,
modifier: Modifier = Modifier,
) {
NavHost(
navController = navController,
startDestination = Routes.HOME,
modifier = modifier,
) {
composable(Routes.HOME) {
HomeScreen(brand = brand)
}
composable(Routes.NEWS) {
NewsScreen(onOpenPost = { category, idOrSlug ->
navController.navigate(Routes.post(category, idOrSlug))
})
}
composable(
route = Routes.POST,
arguments = listOf(
navArgument(Routes.Args.CATEGORY) { type = NavType.StringType },
navArgument(Routes.Args.ID_OR_SLUG) { type = NavType.StringType },
),
) {
PostScreen()
}
composable(Routes.WIKI) {
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
}
composable(
route = Routes.WIKI_PAGE,
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
) {
WikiPageScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
}
composable(
route = Routes.PAGE,
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
) {
PageScreen()
}
composable(Routes.CONTACT) {
ContactScreen()
}
}
}
/** Navigate to a top-level menu destination: single instance, reset to it. */
private fun NavHostController.navigateTopLevel(route: String) {
navigate(route) {
popUpTo(Routes.HOME) { saveState = true }
launchSingleTop = true
restoreState = true
}
}

View File

@@ -0,0 +1,54 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui
import com.runicgateway.app.core.result.ApiResult
/**
* The load state a screen renders. View models expose a `StateFlow<UiState<T>>`;
* the Compose layer renders loading / content / error+retry from it (PLAN.md §7:
* clean loading/error/retry states, no crash on a down backend).
*/
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val kind: ErrorKind, val httpStatus: Int? = null) : UiState<Nothing>
}
/**
* Coarse failure buckets the UI turns into a friendly message + retry. Kept
* transport-agnostic so a screen picks the right copy without inspecting raw
* exceptions or status codes.
*/
enum class ErrorKind {
/** No answer from the backend — offline, DNS, TLS, timeout. */
NETWORK,
/** Requested item doesn't exist (404). */
NOT_FOUND,
/** Too many requests (429) — back off and retry shortly. */
RATE_LIMITED,
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
SHARD_OFFLINE,
/** Any other non-2xx server response. */
SERVER,
}
/** Fold an [ApiResult] into a [UiState], mapping known statuses to [ErrorKind]s. */
fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
is ApiResult.Ok -> UiState.Success(data)
is ApiResult.NetworkError -> UiState.Error(ErrorKind.NETWORK)
is ApiResult.HttpError -> UiState.Error(
kind = when (status) {
404 -> ErrorKind.NOT_FOUND
429 -> ErrorKind.RATE_LIMITED
503 -> ErrorKind.SHARD_OFFLINE
else -> ErrorKind.SERVER
},
httpStatus = status,
)
}

View File

@@ -0,0 +1,40 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.components
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
/**
* Renders server-authored HTML (post/wiki bodies, `rich_text` blocks) via a
* native [TextView] + [HtmlCompat]. Compose has no HTML renderer; for the
* functional pass this is the pragmatic path and handles links, lists, and
* basic markup. The body is already sanitized server-side (website `cleanBody`).
* The M5 design pass can revisit richer/native rendering.
*/
@Composable
fun HtmlText(html: String, modifier: Modifier = Modifier) {
val textColor = LocalContentColor.current.toArgb()
val linkColor = MaterialTheme.colorScheme.primary.toArgb()
AndroidView(
modifier = modifier,
factory = { context ->
TextView(context).apply {
movementMethod = LinkMovementMethod.getInstance()
}
},
update = { view ->
view.setTextColor(textColor)
view.setLinkTextColor(linkColor)
view.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
},
)
}

View File

@@ -0,0 +1,87 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.runicgateway.app.R
import com.runicgateway.app.ui.ErrorKind
/** Centered progress indicator for a whole-screen loading state (§7). */
@Composable
fun LoadingView(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
}
/**
* Whole-screen error state with a friendly, kind-specific message and a Retry
* button (§7). Copy is resolved from string resources so it stays localizable.
*/
@Composable
fun ErrorView(
kind: ErrorKind,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(errorMessageRes(kind)),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp).width(160.dp),
) {
Text(stringResource(R.string.action_retry))
}
}
}
/** Centered informational message for an empty list (§7). */
@Composable
fun EmptyView(message: String, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
}
}
private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
ErrorKind.NETWORK -> R.string.error_network
ErrorKind.NOT_FOUND -> R.string.error_not_found
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
ErrorKind.SERVER -> R.string.error_server
}

View File

@@ -0,0 +1,112 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.connect
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.ui.connect.ConnectViewModel.ConnectError
/**
* First-run "Connect to your shard's website" screen (PLAN.md §3). Nothing else
* in the app runs until a valid Runic Gateway site is entered and validated.
*/
@Composable
fun ConnectScreen(
onConnected: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ConnectViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(R.string.connect_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.connect_subtitle),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
)
OutlinedTextField(
value = state.input,
onValueChange = viewModel::onInputChange,
singleLine = true,
enabled = !state.submitting,
isError = state.error != null,
label = { Text(stringResource(R.string.connect_url_label)) },
placeholder = { Text(stringResource(R.string.connect_url_hint)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { viewModel.connect(onConnected) }),
supportingText = state.error?.let { err ->
{ Text(errorMessage(err), color = MaterialTheme.colorScheme.error) }
},
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { viewModel.connect(onConnected) },
enabled = !state.submitting,
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
) {
if (state.submitting) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Text(stringResource(R.string.connect_button))
}
}
}
}
@Composable
private fun errorMessage(error: ConnectError): String = when (error) {
ConnectError.Blank -> stringResource(R.string.connect_error_blank)
ConnectError.Malformed -> stringResource(R.string.connect_error_malformed)
ConnectError.UnsupportedScheme -> stringResource(R.string.connect_error_scheme)
ConnectError.Insecure -> stringResource(R.string.connect_error_insecure)
ConnectError.NotRunicGateway -> stringResource(R.string.connect_error_not_runic)
ConnectError.Unreachable -> stringResource(R.string.connect_error_unreachable)
is ConnectError.Server -> stringResource(R.string.connect_error_server, error.status)
}

View File

@@ -0,0 +1,80 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.connect
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.ServerUrl
import com.runicgateway.app.data.repository.ConnectionRepository
import com.runicgateway.app.data.repository.ConnectionRepository.ProbeResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives the first-run connect screen (PLAN.md §3): validates + probes the
* entered base URL, and on success activates it for the whole app. The error is
* exposed as a resource-free [ConnectError] the screen maps to localized copy.
*/
@HiltViewModel
class ConnectViewModel @Inject constructor(
private val connectionRepository: ConnectionRepository,
) : ViewModel() {
sealed interface ConnectError {
data object Blank : ConnectError
data object Malformed : ConnectError
data object UnsupportedScheme : ConnectError
data object Insecure : ConnectError
data object NotRunicGateway : ConnectError
data object Unreachable : ConnectError
data class Server(val status: Int) : ConnectError
}
data class UiState(
val input: String = "",
val submitting: Boolean = false,
val error: ConnectError? = null,
)
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()
fun onInputChange(value: String) {
_state.update { it.copy(input = value, error = null) }
}
fun connect(onSuccess: () -> Unit) {
if (_state.value.submitting) return
_state.update { it.copy(submitting = true, error = null) }
viewModelScope.launch {
val result = connectionRepository.probeAndConnect(_state.value.input)
when (result) {
is ProbeResult.Success -> {
_state.update { it.copy(submitting = false) }
onSuccess()
}
is ProbeResult.InvalidUrl -> fail(result.reason.toError())
ProbeResult.NotRunicGateway -> fail(ConnectError.NotRunicGateway)
is ProbeResult.Unreachable -> fail(ConnectError.Unreachable)
is ProbeResult.ServerError -> fail(ConnectError.Server(result.status))
}
}
}
private fun fail(error: ConnectError) {
_state.update { it.copy(submitting = false, error = error) }
}
private fun ServerUrl.Reason.toError(): ConnectError = when (this) {
ServerUrl.Reason.BLANK -> ConnectError.Blank
ServerUrl.Reason.MALFORMED -> ConnectError.Malformed
ServerUrl.Reason.UNSUPPORTED_SCHEME -> ConnectError.UnsupportedScheme
ServerUrl.Reason.INSECURE -> ConnectError.Insecure
}
}

View File

@@ -0,0 +1,121 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.contact
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.contact.ContactViewModel.Result
/** Contact form (PLAN.md §6.1). Handles validation, send, mailto-fallback, errors. */
@Composable
fun ContactScreen(
modifier: Modifier = Modifier,
viewModel: ContactViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Text(stringResource(R.string.contact_title), style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
singleLine = true,
enabled = !state.submitting,
label = { Text(stringResource(R.string.contact_name)) },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.email,
onValueChange = viewModel::onEmailChange,
singleLine = true,
enabled = !state.submitting,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
label = { Text(stringResource(R.string.contact_email)) },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.message,
onValueChange = viewModel::onMessageChange,
enabled = !state.submitting,
minLines = 4,
label = { Text(stringResource(R.string.contact_message)) },
modifier = Modifier.fillMaxWidth(),
)
state.result?.let { ResultMessage(it) }
Button(
onClick = viewModel::send,
enabled = !state.submitting,
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
) {
if (state.submitting) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Text(stringResource(R.string.contact_send))
}
}
}
}
@Composable
private fun ResultMessage(result: Result) {
val (text, isError) = when (result) {
Result.Sent -> stringResource(R.string.contact_sent) to false
is Result.Fallback -> stringResource(R.string.contact_fallback, result.email ?: "") to false
Result.ValidationError -> stringResource(R.string.contact_validation) to true
is Result.Failed -> errorText(result.kind) to true
}
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 12.dp),
)
}
@Composable
private fun errorText(kind: ErrorKind): String = when (kind) {
ErrorKind.NETWORK -> stringResource(R.string.error_network)
ErrorKind.RATE_LIMITED -> stringResource(R.string.error_rate_limited)
else -> stringResource(R.string.contact_error)
}

View File

@@ -0,0 +1,79 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.contact
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.repository.ContactRepository
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.toUiState
import com.runicgateway.app.ui.UiState as LoadUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Contact form submission with client-side validation (PLAN.md §6.1). */
@HiltViewModel
class ContactViewModel @Inject constructor(
private val contactRepository: ContactRepository,
) : ViewModel() {
sealed interface Result {
data object Sent : Result
/** Site has no mailer; email directly at [email] instead. */
data class Fallback(val email: String?) : Result
data object ValidationError : Result
data class Failed(val kind: ErrorKind) : Result
}
data class UiState(
val name: String = "",
val email: String = "",
val message: String = "",
val submitting: Boolean = false,
val result: Result? = null,
)
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()
fun onNameChange(value: String) = _state.update { it.copy(name = value, result = null) }
fun onEmailChange(value: String) = _state.update { it.copy(email = value, result = null) }
fun onMessageChange(value: String) = _state.update { it.copy(message = value, result = null) }
fun send() {
val s = _state.value
if (s.submitting) return
if (s.name.isBlank() || s.email.isBlank() || s.message.isBlank()) {
_state.update { it.copy(result = Result.ValidationError) }
return
}
_state.update { it.copy(submitting = true, result = null) }
viewModelScope.launch {
val result = when (val r = contactRepository.send(s.name.trim(), s.email.trim(), s.message.trim())) {
is ApiResult.Ok ->
if (r.data.sent) Result.Sent else Result.Fallback(r.data.email)
else -> {
val kind = (r.toUiState() as? LoadUiState.Error)?.kind ?: ErrorKind.SERVER
Result.Failed(kind)
}
}
_state.update {
// Clear the fields on a successful send; keep them on failure so the
// user can retry without retyping.
if (result is Result.Sent) {
UiState(result = result)
} else {
it.copy(submitting = false, result = result)
}
}
}
}
}

View File

@@ -0,0 +1,122 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.home
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
/**
* Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a
* live site-status card (online vs maintenance) with the backend version.
*/
@Composable
fun HomeScreen(
brand: BrandDto?,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> HomeContent(brand, s.data, modifier)
}
}
@Composable
private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Text(
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,
)
brand?.tagline?.takeIf { it.isNotBlank() }?.let { tagline ->
Text(
text = tagline,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
Spacer(Modifier.height(20.dp))
StatusCard(status)
brand?.description?.takeIf { it.isNotBlank() }?.let { desc ->
Spacer(Modifier.height(16.dp))
Text(text = desc, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
private fun StatusCard(status: StatusDto) {
val online = !status.isMaintenance
val containerColor =
if (online) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.errorContainer
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = containerColor),
) {
Column(Modifier.padding(16.dp)) {
Text(
text = stringResource(
if (online) R.string.home_status_live else R.string.home_status_maintenance,
),
style = MaterialTheme.typography.titleLarge,
)
if (status.isMaintenance && status.statusMessage.isNotBlank()) {
Text(
text = status.statusMessage,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 6.dp),
)
}
if (status.version.server.isNotBlank()) {
Text(
text = stringResource(
R.string.home_server_version,
status.version.server,
status.version.api,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp),
)
}
}
}
}

View File

@@ -0,0 +1,36 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Loads site status (mode + version) for the Home screen (PLAN.md §6.1). */
@HiltViewModel
class HomeViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<StatusDto>>(UiState.Loading)
val state: StateFlow<UiState<StatusDto>> = _state.asStateFlow()
init { load() }
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = settingsRepository.getStatus().toUiState()
}
}
}

View File

@@ -0,0 +1,35 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
/**
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
* plain strings for Navigation-Compose; argument-bearing routes expose a
* `build(...)` helper so call sites never hand-format paths.
*/
object Routes {
const val HOME = "home"
const val NEWS = "news"
const val WIKI = "wiki"
const val CONTACT = "contact"
/** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
const val PAGE = "page/{slug}"
/** News post detail by category + id-or-slug. */
const val POST = "news/{category}/{idOrSlug}"
/** Wiki page detail by slug. */
const val WIKI_PAGE = "wiki/{slug}"
object Args {
const val SLUG = "slug"
const val CATEGORY = "category"
const val ID_OR_SLUG = "idOrSlug"
}
fun page(slug: String) = "page/$slug"
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
fun wikiPage(slug: String) = "wiki/$slug"
}

View File

@@ -0,0 +1,117 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.news
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
/** News hub with category tabs and a post list (PLAN.md §6.1). */
@Composable
fun NewsScreen(
onOpenPost: (categoryUrlSlug: String, idOrSlug: String) -> Unit,
modifier: Modifier = Modifier,
viewModel: NewsViewModel = hiltViewModel(),
) {
val category by viewModel.category.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
Column(modifier = modifier.fillMaxSize()) {
ScrollableTabRow(selectedTabIndex = PostCategory.entries.indexOf(category)) {
PostCategory.entries.forEach { cat ->
Tab(
selected = cat == category,
onClick = { viewModel.selectCategory(cat) },
text = { Text(stringResource(categoryLabel(cat))) },
)
}
}
when (val s = state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
is UiState.Success -> {
if (s.data.isEmpty()) {
EmptyView(stringResource(R.string.news_empty))
} else {
PostList(s.data, category.urlSlug, onOpenPost)
}
}
}
}
}
@Composable
private fun PostList(
posts: List<PostDto>,
categoryUrlSlug: String,
onOpenPost: (String, String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
) {
items(posts, key = { it.id }) { post ->
PostRow(post) { onOpenPost(categoryUrlSlug, post.slug ?: post.id.toString()) }
}
}
}
@Composable
private fun PostRow(post: PostDto, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable(onClick = onClick),
) {
Column(Modifier.padding(16.dp)) {
Text(post.title, style = MaterialTheme.typography.titleMedium)
post.publishedAt?.let { date ->
Text(
text = date,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
post.excerpt?.takeIf { it.isNotBlank() }?.let { excerpt ->
Text(
text = excerpt,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
}
private fun categoryLabel(category: PostCategory): Int = when (category) {
PostCategory.NEWS -> R.string.news_cat_news
PostCategory.FIVE_ON_FRIDAY -> R.string.news_cat_five_on_friday
PostCategory.NEWSLETTER -> R.string.news_cat_newsletter
PostCategory.SCREENSHOTS -> R.string.news_cat_screenshots
}

View File

@@ -0,0 +1,46 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.news
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** News hub: a category selector over the four post feeds (PLAN.md §6.1). */
@HiltViewModel
class NewsViewModel @Inject constructor(
private val contentRepository: ContentRepository,
) : ViewModel() {
private val _category = MutableStateFlow(PostCategory.NEWS)
val category: StateFlow<PostCategory> = _category.asStateFlow()
private val _state = MutableStateFlow<UiState<List<PostDto>>>(UiState.Loading)
val state: StateFlow<UiState<List<PostDto>>> = _state.asStateFlow()
init { load() }
fun selectCategory(category: PostCategory) {
if (category == _category.value) return
_category.value = category
load()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = contentRepository.getPosts(_category.value).toUiState()
}
}
}

View File

@@ -0,0 +1,80 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.news
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.ui.LocalAssetResolver
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.HtmlText
import com.runicgateway.app.ui.components.LoadingView
/** Post detail: title, date, hero image, and HTML body (PLAN.md §6.1). */
@Composable
fun PostScreen(
modifier: Modifier = Modifier,
viewModel: PostViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> PostContent(s.data, modifier)
}
}
@Composable
private fun PostContent(post: PostDto, modifier: Modifier = Modifier) {
val resolveAsset = LocalAssetResolver.current
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Text(post.title, style = MaterialTheme.typography.headlineSmall)
post.publishedAt?.let { date ->
Text(
text = date,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
resolveAsset(post.imageUrl)?.let { url ->
Spacer(Modifier.height(16.dp))
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
)
}
post.body?.takeIf { it.isNotBlank() }?.let { body ->
Spacer(Modifier.height(16.dp))
HtmlText(body, modifier = Modifier.fillMaxWidth())
}
}
}

View File

@@ -0,0 +1,49 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.news
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Loads a single post for the detail screen (PLAN.md §6.1). */
@HiltViewModel
class PostViewModel @Inject constructor(
private val contentRepository: ContentRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val categorySlug: String? = savedStateHandle[Routes.Args.CATEGORY]
private val idOrSlug: String = savedStateHandle[Routes.Args.ID_OR_SLUG] ?: ""
private val _state = MutableStateFlow<UiState<PostDto>>(UiState.Loading)
val state: StateFlow<UiState<PostDto>> = _state.asStateFlow()
init { load() }
fun load() {
val category = PostCategory.fromUrlSlug(categorySlug)
if (category == null) {
_state.value = UiState.Error(ErrorKind.NOT_FOUND)
return
}
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = contentRepository.getPost(category, idOrSlug).toUiState()
}
}
}

View File

@@ -0,0 +1,152 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.page
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.runicgateway.app.data.api.dto.BlockDto
import com.runicgateway.app.ui.LocalAssetResolver
import com.runicgateway.app.ui.components.HtmlText
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Renders the block-based CMS page body (PLAN.md §6.1). Dispatches on the block
* `type` from the server registry (`heading | rich_text | image | quote | cta |
* divider | two_column`); unknown or invisible blocks are skipped so a new
* server-side block type never breaks an older app.
*/
@Composable
fun BlockList(blocks: List<BlockDto>, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
blocks.filter { it.visible }.forEach { block ->
BlockView(block)
Spacer(Modifier.height(12.dp))
}
}
}
@Composable
private fun BlockView(block: BlockDto) {
val props = block.props
when (block.type) {
"heading" -> {
val text = props.str("text").orEmpty()
val level = props.int("level") ?: 2
Text(text = text, style = headingStyle(level))
}
"rich_text" -> {
props.str("html")?.takeIf { it.isNotBlank() }?.let {
HtmlText(it, modifier = Modifier.fillMaxWidth())
}
}
"image" -> {
val resolveAsset = LocalAssetResolver.current
resolveAsset(props.str("src"))?.let { url ->
AsyncImage(
model = url,
contentDescription = props.str("alt"),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
)
}
props.str("caption")?.takeIf { it.isNotBlank() }?.let { caption ->
Text(
text = caption,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
"quote" -> {
Column(Modifier.padding(start = 12.dp)) {
Text(
text = props.str("text").orEmpty(),
style = MaterialTheme.typography.titleMedium.copy(fontStyle = FontStyle.Italic),
)
props.str("attribution")?.takeIf { it.isNotBlank() }?.let { attribution ->
Text(
text = "$attribution",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
"cta" -> {
val resolveAsset = LocalAssetResolver.current
val uriHandler = LocalUriHandler.current
val label = props.str("text").orEmpty()
val url = resolveAsset(props.str("url"))
val onClick: () -> Unit = { url?.let { runCatching { uriHandler.openUri(it) } } }
if (props.str("style") == "secondary") {
OutlinedButton(onClick = onClick) { Text(label) }
} else {
Button(onClick = onClick) { Text(label) }
}
}
"divider" -> HorizontalDivider()
"two_column" -> {
// Stack the two columns vertically on a phone (§2.1 functional pass).
BlockList(props.blocks("left"))
BlockList(props.blocks("right"))
}
else -> Unit // unknown block type — skip rather than crash
}
}
@Composable
private fun headingStyle(level: Int) = when (level) {
1 -> MaterialTheme.typography.headlineMedium
2 -> MaterialTheme.typography.headlineSmall
3 -> MaterialTheme.typography.titleLarge
else -> MaterialTheme.typography.titleMedium
}
// ── Prop readers (tolerant of missing/typed values) ──────────────────────
private val blockJson = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
private fun JsonObject.str(key: String): String? =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.int(key: String): Int? =
(this[key] as? JsonPrimitive)?.content?.toIntOrNull()
private fun JsonObject.blocks(key: String): List<BlockDto> {
val array = this[key] as? JsonArray ?: return emptyList()
return runCatching {
blockJson.decodeFromJsonElement(ListSerializer(BlockDto.serializer()), array)
}.getOrDefault(emptyList())
}

View File

@@ -0,0 +1,54 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.page
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
/** CMS page detail: title + rendered blocks (PLAN.md §6.1). */
@Composable
fun PageScreen(
modifier: Modifier = Modifier,
viewModel: PageViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> PageContent(s.data, modifier)
}
}
@Composable
private fun PageContent(page: PageDto, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Text(page.title, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(16.dp))
BlockList(page.blocks, modifier = Modifier.fillMaxWidth())
}
}

View File

@@ -0,0 +1,41 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.page
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Loads a block-based CMS page by slug (PLAN.md §6.1). */
@HiltViewModel
class PageViewModel @Inject constructor(
private val contentRepository: ContentRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val slug: String = savedStateHandle[Routes.Args.SLUG] ?: ""
private val _state = MutableStateFlow<UiState<PageDto>>(UiState.Loading)
val state: StateFlow<UiState<PageDto>> = _state.asStateFlow()
init { load() }
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = contentRepository.getPage(slug).toUiState()
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.ui.graphics.Color
/**
* Parses a brand accent hex string (`#RGB`, `#RRGGBB`, or `#AARRGGBB`, with or
* without the leading `#`) into a Compose [Color]. Returns null for anything
* unparseable so the theme falls back to its default scheme (PLAN.md §3, §5).
* Pure logic — covered by JVM unit tests.
*/
fun parseBrandColor(hex: String?): Color? {
if (hex.isNullOrBlank()) return null
val cleaned = hex.trim().removePrefix("#")
val normalized = when (cleaned.length) {
3 -> cleaned.map { "$it$it" }.joinToString("") // #abc -> aabbcc
6, 8 -> cleaned
else -> return null
}
if (!normalized.all { it.isHexDigit() }) return null
val rgb = normalized.takeLast(6)
val alpha = if (normalized.length == 8) normalized.take(2) else "ff"
return try {
Color((alpha + rgb).toLong(16))
} catch (_: NumberFormatException) {
null
}
}
private fun Char.isHexDigit(): Boolean =
this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F'

View File

@@ -3,15 +3,12 @@
*/
package com.runicgateway.app.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
@@ -26,23 +23,29 @@ private val LightColorScheme = lightColorScheme(
)
/**
* App theme for the M0 skeleton. Dynamic color (Android 12+) is used when
* available; otherwise a static placeholder scheme. The M5 design pass wires
* the color scheme to per-shard branding (PLAN.md §3, §5).
* App theme for the functional pass. The color scheme is seeded from the
* per-shard brand accent (PLAN.md §3: the app themes itself from the site's
* branding) when one is available, so the app takes on each shard's color; it
* falls back to a neutral placeholder scheme before connect or when a site
* publishes no accent.
*
* This is deliberately a minimal seeding — a single-color override on the
* default Material 3 schemes. The M5 design pass replaces it with a full,
* designed color system (§2.1, §5); nothing here is meant to be the final look.
*/
@Composable
fun RunicGatewayTheme(
accent: Color? = null,
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
val base = if (darkTheme) DarkColorScheme else LightColorScheme
val colorScheme = if (accent != null) {
// Brand consistency across a shard wins over device dynamic color: seed
// the primary role from the accent so buttons/highlights carry the brand.
base.copy(primary = accent, secondary = accent)
} else {
base
}
MaterialTheme(

View File

@@ -0,0 +1,113 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.wiki
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.HtmlText
import com.runicgateway.app.ui.components.LoadingView
/** Wiki page detail: title, category, HTML body, tags, and backlinks (PLAN.md §6.1). */
@Composable
fun WikiPageScreen(
onOpenPage: (slug: String) -> Unit,
modifier: Modifier = Modifier,
viewModel: WikiPageViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> WikiPageContent(s.data, onOpenPage, modifier)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun WikiPageContent(
page: WikiPageDto,
onOpenPage: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Text(page.title, style = MaterialTheme.typography.headlineSmall)
page.categoryTitle?.takeIf { it.isNotBlank() }?.let { cat ->
Text(
text = cat,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
page.body?.takeIf { it.isNotBlank() }?.let { body ->
Spacer(Modifier.height(16.dp))
HtmlText(body, modifier = Modifier.fillMaxWidth())
}
if (page.tags.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
Text(stringResource(R.string.wiki_section_tags), style = MaterialTheme.typography.titleSmall)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 8.dp),
) {
page.tags.forEach { tag ->
AssistChip(
onClick = { },
label = { Text(tag.label) },
border = AssistChipDefaults.assistChipBorder(enabled = true),
)
}
}
}
if (page.backlinks.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
HorizontalDivider()
Text(
text = stringResource(R.string.wiki_section_backlinks),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp),
)
page.backlinks.forEach { backlink ->
TextButton(onClick = { onOpenPage(backlink.slug) }) {
Text(backlink.title)
}
}
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.wiki
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.repository.WikiRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Loads a single wiki page for the detail screen (PLAN.md §6.1). */
@HiltViewModel
class WikiPageViewModel @Inject constructor(
private val wikiRepository: WikiRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val slug: String = savedStateHandle[Routes.Args.SLUG] ?: ""
private val _state = MutableStateFlow<UiState<WikiPageDto>>(UiState.Loading)
val state: StateFlow<UiState<WikiPageDto>> = _state.asStateFlow()
init { load() }
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = wikiRepository.getPage(slug).toUiState()
}
}
}

View File

@@ -0,0 +1,102 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.wiki
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.WikiSummaryDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
/** Wiki index: search field + page list (PLAN.md §6.1). */
@Composable
fun WikiScreen(
onOpenPage: (slug: String) -> Unit,
modifier: Modifier = Modifier,
viewModel: WikiViewModel = hiltViewModel(),
) {
val query by viewModel.query.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
Column(modifier = modifier.fillMaxSize()) {
OutlinedTextField(
value = query,
onValueChange = viewModel::onQueryChange,
singleLine = true,
label = { Text(stringResource(R.string.wiki_search_hint)) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { viewModel.load() }),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
when (val s = state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
is UiState.Success -> {
if (s.data.isEmpty()) {
EmptyView(stringResource(R.string.wiki_empty))
} else {
WikiList(s.data, onOpenPage)
}
}
}
}
}
@Composable
private fun WikiList(pages: List<WikiSummaryDto>, onOpenPage: (String) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
items(pages, key = { it.id }) { page ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable { onOpenPage(page.slug) },
) {
Column(Modifier.padding(16.dp)) {
Text(page.title, style = MaterialTheme.typography.titleMedium)
page.categoryTitle?.takeIf { it.isNotBlank() }?.let { cat ->
Text(
text = cat,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
page.excerpt?.takeIf { it.isNotBlank() }?.let { excerpt ->
Text(
text = excerpt,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.wiki
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.WikiSummaryDto
import com.runicgateway.app.data.repository.WikiRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Wiki index with full-text search (PLAN.md §6.1). */
@HiltViewModel
class WikiViewModel @Inject constructor(
private val wikiRepository: WikiRepository,
) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _state = MutableStateFlow<UiState<List<WikiSummaryDto>>>(UiState.Loading)
val state: StateFlow<UiState<List<WikiSummaryDto>>> = _state.asStateFlow()
init { load() }
fun onQueryChange(value: String) {
_query.value = value
}
/** Run the current query (invoked on submit; blank query lists all pages). */
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = wikiRepository.getPages(query = _query.value).toUiState()
}
}
}

View File

@@ -8,5 +8,69 @@
<resources>
<!-- Fixed launcher name, baked at build even though in-app branding is per-shard (PLAN.md §13). -->
<string name="app_name">Runic Gateway</string>
<string name="app_scaffold_ready">Runic Gateway — scaffold ready</string>
<!-- ── Shared actions / errors ─────────────────────────────────────── -->
<string name="action_retry">Retry</string>
<string name="action_back">Back</string>
<string name="error_network">Can\'t reach the site. Check your connection and try again.</string>
<string name="error_not_found">This content couldn\'t be found.</string>
<string name="error_rate_limited">Too many requests. Please try again in a moment.</string>
<string name="error_shard_offline">The shard is offline right now.</string>
<string name="error_server">Something went wrong on the server. Please try again.</string>
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
<string name="connect_title">Connect to your shard</string>
<string name="connect_subtitle">Enter your shard\'s website address to get started.</string>
<string name="connect_url_label">Website address</string>
<string name="connect_url_hint">https://your-shard.example.com</string>
<string name="connect_button">Connect</string>
<string name="connect_connecting">Connecting…</string>
<string name="connect_error_blank">Enter a website address.</string>
<string name="connect_error_malformed">That doesn\'t look like a valid web address.</string>
<string name="connect_error_scheme">Only http and https addresses are supported.</string>
<string name="connect_error_insecure">A secure https address is required.</string>
<string name="connect_error_not_runic">That site isn\'t a Runic Gateway shard.</string>
<string name="connect_error_unreachable">Couldn\'t reach that site. Check the address and your connection.</string>
<string name="connect_error_server">The site responded with an error (%1$d). Try again shortly.</string>
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
<string name="nav_open_menu">Open navigation menu</string>
<string name="menu_home">Home</string>
<string name="menu_news">News</string>
<string name="menu_wiki">Wiki</string>
<string name="menu_about">About</string>
<string name="menu_contact">Contact</string>
<string name="menu_change_server">Change server</string>
<!-- ── Home / status (§6.1) ────────────────────────────────────────── -->
<string name="home_status_live">Online</string>
<string name="home_status_maintenance">Under maintenance</string>
<string name="home_server_version">Server %1$s · API %2$s</string>
<!-- ── News & content (§6.1) ───────────────────────────────────────── -->
<string name="news_title">News &amp; content</string>
<string name="news_cat_news">News</string>
<string name="news_cat_screenshots">Screenshots</string>
<string name="news_cat_five_on_friday">Five on Friday</string>
<string name="news_cat_newsletter">Newsletter</string>
<string name="news_empty">No posts here yet.</string>
<!-- ── Wiki (§6.1) ─────────────────────────────────────────────────── -->
<string name="wiki_title">Wiki</string>
<string name="wiki_search_hint">Search the wiki</string>
<string name="wiki_empty">No pages found.</string>
<string name="wiki_section_tags">Tags</string>
<string name="wiki_section_backlinks">Linked from</string>
<!-- ── Contact (§6.1) ──────────────────────────────────────────────── -->
<string name="contact_title">Contact</string>
<string name="contact_name">Your name</string>
<string name="contact_email">Your email</string>
<string name="contact_message">Message</string>
<string name="contact_send">Send message</string>
<string name="contact_sending">Sending…</string>
<string name="contact_sent">Message sent — thank you!</string>
<string name="contact_fallback">This site has no mailer configured. Email directly: %1$s</string>
<string name="contact_validation">Please fill in every field.</string>
<string name="contact_error">Couldn\'t send your message. Please try again.</string>
</resources>

View File

@@ -0,0 +1,59 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Unit tests for retargeting a sentinel-hosted request onto the configured base
* URL (PLAN.md §3). Covers the base-path-prefix case that a naive host swap gets
* wrong.
*/
class HostRewriteTest {
private fun rewrite(base: String, request: String) =
rewriteOntoBase(base.toHttpUrl(), request.toHttpUrl()).toString()
@Test fun swapsHostForRootBase() {
assertEquals(
"https://shard.example.com/api/v1/public/status",
rewrite(
base = "https://shard.example.com/",
request = "https://runic-gateway.invalid/api/v1/public/status",
),
)
}
@Test fun preservesBasePathPrefixWithoutDoubleSlash() {
assertEquals(
"https://host.example.com/uo/api/v1/public/status",
rewrite(
base = "https://host.example.com/uo/",
request = "https://runic-gateway.invalid/api/v1/public/status",
),
)
}
@Test fun preservesQueryString() {
assertEquals(
"https://shard.example.com/api/v1/public/wiki?q=magic",
rewrite(
base = "https://shard.example.com/",
request = "https://runic-gateway.invalid/api/v1/public/wiki?q=magic",
),
)
}
@Test fun keepsPortAndScheme() {
assertEquals(
"http://127.0.0.1:3000/api/v1/public/status",
rewrite(
base = "http://127.0.0.1:3000/",
request = "https://runic-gateway.invalid/api/v1/public/status",
),
)
}
}

View File

@@ -0,0 +1,63 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Unit tests for the first-run base-URL normalization (PLAN.md §3). */
class ServerUrlTest {
private fun valid(raw: String, allowInsecure: Boolean = true) =
(ServerUrl.normalize(raw, allowInsecure) as ServerUrl.Result.Valid).url
private fun invalid(raw: String, allowInsecure: Boolean = true) =
(ServerUrl.normalize(raw, allowInsecure) as ServerUrl.Result.Invalid).reason
@Test fun blankIsRejected() {
assertEquals(ServerUrl.Reason.BLANK, invalid(""))
assertEquals(ServerUrl.Reason.BLANK, invalid(" "))
}
@Test fun nonHttpSchemeIsRejected() {
assertEquals(ServerUrl.Reason.UNSUPPORTED_SCHEME, invalid("ftp://example.com"))
assertEquals(ServerUrl.Reason.UNSUPPORTED_SCHEME, invalid("ws://example.com"))
}
@Test fun garbageIsMalformed() {
assertEquals(ServerUrl.Reason.MALFORMED, invalid("has spaces in it"))
}
@Test fun httpRejectedWhenInsecureDisallowed() {
assertEquals(ServerUrl.Reason.INSECURE, invalid("http://example.com", allowInsecure = false))
}
@Test fun httpAllowedInDebug() {
val url = valid("http://127.0.0.1:3000", allowInsecure = true)
assertEquals("http", url.scheme)
assertEquals(3000, url.port)
}
@Test fun schemelessInputGetsHttps() {
val url = valid("shard.example.com", allowInsecure = false)
assertEquals("https", url.scheme)
assertEquals("shard.example.com", url.host)
assertEquals("/", url.encodedPath)
}
@Test fun trailingSlashIsEnforced() {
assertTrue(valid("https://example.com").toString().endsWith("/"))
assertEquals("/base/", valid("https://example.com/base").encodedPath)
assertEquals("/base/", valid("https://example.com/base/").encodedPath)
}
@Test fun queryAndFragmentAreStripped() {
val url = valid("https://example.com/base?token=abc#frag")
assertEquals("/base/", url.encodedPath)
assertNull(url.query)
assertEquals("https://example.com/base/", url.toString())
}
}

View File

@@ -0,0 +1,51 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.result
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
/** Unit tests for the typed-result plumbing (PLAN.md §7). */
class ApiResultTest {
@Test fun okWrapsValue() = runTest {
val result = safeApiCall { 42 }
assertEquals(ApiResult.Ok(42), result)
}
@Test fun httpExceptionBecomesHttpError() = runTest {
val result = safeApiCall {
throw HttpException(Response.error<Any>(404, "nope".toResponseBody(null)))
}
assertTrue(result is ApiResult.HttpError)
assertEquals(404, (result as ApiResult.HttpError).status)
}
@Test fun ioExceptionBecomesNetworkError() = runTest {
val result = safeApiCall { throw IOException("offline") }
assertTrue(result is ApiResult.NetworkError)
}
@Test fun cancellationIsRethrown() = runTest {
assertThrows(CancellationException::class.java) {
kotlinx.coroutines.runBlocking {
safeApiCall { throw CancellationException("cancelled") }
}
}
}
@Test fun mapTransformsOnlyOk() {
assertEquals(ApiResult.Ok(4), ApiResult.Ok(2).map { it * 2 })
val err: ApiResult<Int> = ApiResult.HttpError(500)
assertEquals(err, err.map { it * 2 })
}
}

View File

@@ -0,0 +1,39 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui
import com.runicgateway.app.core.result.ApiResult
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
/** Unit tests for folding an [ApiResult] into a screen [UiState] (PLAN.md §7). */
class UiStateTest {
@Test fun okBecomesSuccess() {
assertEquals(UiState.Success("hi"), ApiResult.Ok("hi").toUiState())
}
@Test fun networkErrorMapsToNetworkKind() {
val state = ApiResult.NetworkError(IOException()).toUiState()
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
}
@Test fun knownStatusesMapToKinds() {
assertEquals(ErrorKind.NOT_FOUND, kindOf(404))
assertEquals(ErrorKind.RATE_LIMITED, kindOf(429))
assertEquals(ErrorKind.SHARD_OFFLINE, kindOf(503))
assertEquals(ErrorKind.SERVER, kindOf(500))
}
@Test fun httpStatusIsPreserved() {
val state = ApiResult.HttpError(503).toUiState() as UiState.Error
assertEquals(503, state.httpStatus)
assertTrue(ApiResult.HttpError(503).let { it.status == 503 })
}
private fun kindOf(status: Int): ErrorKind =
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind
}

View File

@@ -0,0 +1,41 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Unit tests for brand accent parsing (PLAN.md §3, §5). */
class BrandColorTest {
@Test fun nullOrBlankReturnsNull() {
assertNull(parseBrandColor(null))
assertNull(parseBrandColor(""))
assertNull(parseBrandColor(" "))
}
@Test fun sixDigitHexParses() {
assertEquals(Color(0xFF7F99BD), parseBrandColor("#7f99bd"))
}
@Test fun hashIsOptional() {
assertEquals(parseBrandColor("#7f99bd"), parseBrandColor("7f99bd"))
}
@Test fun shorthandExpands() {
assertEquals(parseBrandColor("#ffffff"), parseBrandColor("#fff"))
}
@Test fun eightDigitHexKeepsAlpha() {
assertEquals(Color(0x80112233), parseBrandColor("#80112233"))
}
@Test fun invalidReturnsNull() {
assertNull(parseBrandColor("#12"))
assertNull(parseBrandColor("nothex!"))
assertNull(parseBrandColor("#gggggg"))
}
}

View File

@@ -1,5 +1,9 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# Heap raised from 2048m: Android lint's report phase (lintReportDebug) needs
# more than 2 GB on the full app codebase and GC-thrashes to a hang below that
# on the CI runner (it passed at 2 GB only while the M0 scaffold was trivial).
# Metaspace is capped so the larger heap doesn't crowd container RAM.
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.configuration-cache=true

View File

@@ -58,6 +58,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
# DI