feat(m1): Connect & browse — first-run flow, public content, contact #6
@@ -65,7 +65,11 @@ jobs:
|
|||||||
|
|
||||||
# chmod defensively: this runner's checkout doesn't preserve the git
|
# chmod defensively: this runner's checkout doesn't preserve the git
|
||||||
# executable bit, so `./gradlew` alone fails with "Permission denied".
|
# 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
|
- name: Lint, test, assemble debug
|
||||||
run: |
|
run: |
|
||||||
chmod +x ./gradlew
|
chmod +x ./gradlew
|
||||||
./gradlew --no-daemon lint test assembleDebug
|
./gradlew --no-daemon testDebugUnitTest lintDebug assembleDebug
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
|||||||
@@ -7,24 +7,29 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
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.RunicGatewayTheme
|
||||||
|
import com.runicgateway.app.ui.theme.parseBrandColor
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-activity host. Navigation-Compose and the first-run base-URL flow (§3)
|
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
|
||||||
* land in M1; this M0 skeleton only proves the Compose + Hilt + theme wiring.
|
* 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
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -32,33 +37,27 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
RunicGatewayTheme {
|
val appViewModel: AppViewModel = hiltViewModel()
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
val state by appViewModel.state.collectAsStateWithLifecycle()
|
||||||
Placeholder(modifier = Modifier.padding(innerPadding))
|
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
when (val s = state) {
|
||||||
|
AppState.Loading -> LoadingView()
|
||||||
|
AppState.NeedsConnection ->
|
||||||
|
ConnectScreen(onConnected = appViewModel::onConnected)
|
||||||
|
is AppState.Ready ->
|
||||||
|
RunicApp(brand = s.brand, onChangeServer = appViewModel::changeServer)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun Placeholder(modifier: Modifier = Modifier) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.app_scaffold_ready),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun PlaceholderPreview() {
|
|
||||||
RunicGatewayTheme {
|
|
||||||
Placeholder()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
14
app/src/main/java/com/runicgateway/app/core/AppConfig.kt
Normal file
14
app/src/main/java/com/runicgateway/app/core/AppConfig.kt
Normal 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,
|
||||||
|
)
|
||||||
@@ -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/"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
78
app/src/main/java/com/runicgateway/app/core/net/ServerUrl.kt
Normal file
78
app/src/main/java/com/runicgateway/app/core/net/ServerUrl.kt
Normal 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+.-]*://")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
82
app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt
Normal file
82
app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)) }
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
24
app/src/main/java/com/runicgateway/app/di/AppModule.kt
Normal file
24
app/src/main/java/com/runicgateway/app/di/AppModule.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
78
app/src/main/java/com/runicgateway/app/di/NetworkModule.kt
Normal file
78
app/src/main/java/com/runicgateway/app/di/NetworkModule.kt
Normal 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)
|
||||||
|
}
|
||||||
83
app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt
Normal file
83
app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
216
app/src/main/java/com/runicgateway/app/ui/RunicApp.kt
Normal file
216
app/src/main/java/com/runicgateway/app/ui/RunicApp.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/src/main/java/com/runicgateway/app/ui/UiState.kt
Normal file
54
app/src/main/java/com/runicgateway/app/ui/UiState.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt
Normal file
122
app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
117
app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt
Normal file
117
app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/src/main/java/com/runicgateway/app/ui/news/PostScreen.kt
Normal file
80
app/src/main/java/com/runicgateway/app/ui/news/PostScreen.kt
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/src/main/java/com/runicgateway/app/ui/page/BlockRenderer.kt
Normal file
152
app/src/main/java/com/runicgateway/app/ui/page/BlockRenderer.kt
Normal 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())
|
||||||
|
}
|
||||||
54
app/src/main/java/com/runicgateway/app/ui/page/PageScreen.kt
Normal file
54
app/src/main/java/com/runicgateway/app/ui/page/PageScreen.kt
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -3,15 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.theme
|
package com.runicgateway.app.ui.theme
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = Purple80,
|
primary = Purple80,
|
||||||
@@ -26,23 +23,29 @@ private val LightColorScheme = lightColorScheme(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App theme for the M0 skeleton. Dynamic color (Android 12+) is used when
|
* App theme for the functional pass. The color scheme is seeded from the
|
||||||
* available; otherwise a static placeholder scheme. The M5 design pass wires
|
* per-shard brand accent (PLAN.md §3: the app themes itself from the site's
|
||||||
* the color scheme to per-shard branding (PLAN.md §3, §5).
|
* 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
|
@Composable
|
||||||
fun RunicGatewayTheme(
|
fun RunicGatewayTheme(
|
||||||
|
accent: Color? = null,
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
dynamicColor: Boolean = true,
|
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val base = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
val colorScheme = if (accent != null) {
|
||||||
val context = LocalContext.current
|
// Brand consistency across a shard wins over device dynamic color: seed
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
// the primary role from the accent so buttons/highlights carry the brand.
|
||||||
}
|
base.copy(primary = accent, secondary = accent)
|
||||||
darkTheme -> DarkColorScheme
|
} else {
|
||||||
else -> LightColorScheme
|
base
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
|
|||||||
113
app/src/main/java/com/runicgateway/app/ui/wiki/WikiPageScreen.kt
Normal file
113
app/src/main/java/com/runicgateway/app/ui/wiki/WikiPageScreen.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt
Normal file
102
app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,5 +8,69 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<!-- Fixed launcher name, baked at build even though in-app branding is per-shard (PLAN.md §13). -->
|
<!-- 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_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 & 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>
|
</resources>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt
Normal file
39
app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Project-wide Gradle settings.
|
# 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.caching=true
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
|
|
||||||
|
|||||||
@@ -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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
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" }
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
|
||||||
# DI
|
# DI
|
||||||
|
|||||||
Reference in New Issue
Block a user