From 9019ded556a59d75f9050cd11e7f17720eb1b3af Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sun, 19 Jul 2026 17:09:33 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(m1):=20connect=20&=20browse=20?= =?UTF-8?q?=E2=80=94=20first-run=20flow,=20public=20content,=20contact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements M1 (functional Kotlin pass, docs/android/PLAN.md §9): the first-run base-URL connect flow, brand-seeded Material 3 theming from /public/settings, a Retrofit/OkHttp/kotlinx-serialization client with a runtime host-selection interceptor (the base URL is not compiled in), the layered repository stack returning a typed ApiResult for graceful degradation, and functional Compose screens for Home/Status, News (+ post detail), Wiki (+ detail), CMS pages (block renderer), and the contact form. One shared, declarative navigation drawer. No auth yet (M3). DTOs + the Retrofit interface are hand-written and spec-aligned rather than openapi-generated: the committed swagger-output.json is produced by swagger-autogen and its component schemas are meta-descriptive (nested {type, example} wrappers), not codegen-clean, so a hand-authored client module is the pragmatic "checked-in generated module" the plan allows (§2). Shapes were matched against the website controllers/models. JVM unit tests cover URL normalization, host rewriting, ApiResult/UiState mapping, and brand-color parsing. `lint test assembleDebug` green locally. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr --- app/build.gradle.kts | 1 + .../java/com/runicgateway/app/MainActivity.kt | 71 +++--- .../com/runicgateway/app/core/AppConfig.kt | 14 ++ .../app/core/net/BaseUrlHolder.kt | 40 ++++ .../app/core/net/HostSelectionInterceptor.kt | 53 +++++ .../runicgateway/app/core/net/ServerUrl.kt | 78 +++++++ .../app/core/net/UserAgentInterceptor.kt | 24 ++ .../app/core/prefs/ServerPreferences.kt | 48 ++++ .../runicgateway/app/core/result/ApiResult.kt | 49 ++++ .../runicgateway/app/data/api/PublicApi.kt | 82 +++++++ .../app/data/api/dto/ContactDto.kt | 26 +++ .../runicgateway/app/data/api/dto/PageDto.kt | 38 +++ .../runicgateway/app/data/api/dto/PostDto.kt | 26 +++ .../app/data/api/dto/PublicDto.kt | 70 ++++++ .../runicgateway/app/data/api/dto/WikiDto.kt | 76 ++++++ .../data/repository/ConnectionRepository.kt | 95 ++++++++ .../app/data/repository/ContactRepository.kt | 24 ++ .../app/data/repository/ContentRepository.kt | 43 ++++ .../app/data/repository/SettingsRepository.kt | 22 ++ .../app/data/repository/WikiRepository.kt | 37 +++ .../java/com/runicgateway/app/di/AppModule.kt | 24 ++ .../com/runicgateway/app/di/NetworkModule.kt | 78 +++++++ .../com/runicgateway/app/ui/AppViewModel.kt | 83 +++++++ .../runicgateway/app/ui/LocalAssetResolver.kt | 18 ++ .../java/com/runicgateway/app/ui/RunicApp.kt | 216 ++++++++++++++++++ .../java/com/runicgateway/app/ui/UiState.kt | 54 +++++ .../app/ui/components/HtmlText.kt | 40 ++++ .../app/ui/components/StateViews.kt | 87 +++++++ .../app/ui/connect/ConnectScreen.kt | 112 +++++++++ .../app/ui/connect/ConnectViewModel.kt | 80 +++++++ .../app/ui/contact/ContactScreen.kt | 121 ++++++++++ .../app/ui/contact/ContactViewModel.kt | 79 +++++++ .../runicgateway/app/ui/home/HomeScreen.kt | 122 ++++++++++ .../runicgateway/app/ui/home/HomeViewModel.kt | 36 +++ .../runicgateway/app/ui/navigation/Routes.kt | 35 +++ .../runicgateway/app/ui/news/NewsScreen.kt | 117 ++++++++++ .../runicgateway/app/ui/news/NewsViewModel.kt | 46 ++++ .../runicgateway/app/ui/news/PostScreen.kt | 80 +++++++ .../runicgateway/app/ui/news/PostViewModel.kt | 49 ++++ .../runicgateway/app/ui/page/BlockRenderer.kt | 152 ++++++++++++ .../runicgateway/app/ui/page/PageScreen.kt | 54 +++++ .../runicgateway/app/ui/page/PageViewModel.kt | 41 ++++ .../runicgateway/app/ui/theme/BrandColor.kt | 33 +++ .../com/runicgateway/app/ui/theme/Theme.kt | 33 +-- .../app/ui/wiki/WikiPageScreen.kt | 113 +++++++++ .../app/ui/wiki/WikiPageViewModel.kt | 41 ++++ .../runicgateway/app/ui/wiki/WikiScreen.kt | 102 +++++++++ .../runicgateway/app/ui/wiki/WikiViewModel.kt | 44 ++++ app/src/main/res/values/strings.xml | 66 +++++- .../app/core/net/HostRewriteTest.kt | 59 +++++ .../app/core/net/ServerUrlTest.kt | 63 +++++ .../app/core/result/ApiResultTest.kt | 51 +++++ .../com/runicgateway/app/ui/UiStateTest.kt | 39 ++++ .../app/ui/theme/BrandColorTest.kt | 41 ++++ gradle/libs.versions.toml | 1 + 55 files changed, 3275 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/core/AppConfig.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/net/BaseUrlHolder.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/net/HostSelectionInterceptor.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/net/ServerUrl.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/net/UserAgentInterceptor.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/prefs/ServerPreferences.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/ContactDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/PageDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/PostDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/WikiDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/ContactRepository.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/ContentRepository.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/SettingsRepository.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/WikiRepository.kt create mode 100644 app/src/main/java/com/runicgateway/app/di/AppModule.kt create mode 100644 app/src/main/java/com/runicgateway/app/di/NetworkModule.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/LocalAssetResolver.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/RunicApp.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/UiState.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/components/HtmlText.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/connect/ConnectScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/connect/ConnectViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/contact/ContactScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/contact/ContactViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/home/HomeViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/news/NewsViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/news/PostScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/news/PostViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/page/BlockRenderer.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/page/PageScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/page/PageViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/theme/BrandColor.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/wiki/WikiPageScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/wiki/WikiPageViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/wiki/WikiViewModel.kt create mode 100644 app/src/test/java/com/runicgateway/app/core/net/HostRewriteTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/core/net/ServerUrlTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/core/result/ApiResultTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/theme/BrandColorTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 65769c5..5579932 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.core) implementation(libs.androidx.navigation.compose) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/app/src/main/java/com/runicgateway/app/MainActivity.kt b/app/src/main/java/com/runicgateway/app/MainActivity.kt index b879ddf..74153d4 100644 --- a/app/src/main/java/com/runicgateway/app/MainActivity.kt +++ b/app/src/main/java/com/runicgateway/app/MainActivity.kt @@ -7,24 +7,29 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.material3.Surface +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.runicgateway.app.ui.AppViewModel +import com.runicgateway.app.ui.AppViewModel.AppState +import com.runicgateway.app.ui.LocalAssetResolver +import com.runicgateway.app.ui.RunicApp +import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.connect.ConnectScreen import com.runicgateway.app.ui.theme.RunicGatewayTheme +import com.runicgateway.app.ui.theme.parseBrandColor import dagger.hilt.android.AndroidEntryPoint /** - * Single-activity host. Navigation-Compose and the first-run base-URL flow (§3) - * land in M1; this M0 skeleton only proves the Compose + Hilt + theme wiring. + * Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run + * connect screen until a shard site is configured (§3), then the main app. + * The Material theme is seeded from the per-shard brand accent, and asset-path + * resolution is provided to the whole tree. */ @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -32,33 +37,27 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - RunicGatewayTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Placeholder(modifier = Modifier.padding(innerPadding)) + val appViewModel: AppViewModel = hiltViewModel() + val state by appViewModel.state.collectAsStateWithLifecycle() + + 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() - } -} diff --git a/app/src/main/java/com/runicgateway/app/core/AppConfig.kt b/app/src/main/java/com/runicgateway/app/core/AppConfig.kt new file mode 100644 index 0000000..fc368ec --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/AppConfig.kt @@ -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, +) diff --git a/app/src/main/java/com/runicgateway/app/core/net/BaseUrlHolder.kt b/app/src/main/java/com/runicgateway/app/core/net/BaseUrlHolder.kt new file mode 100644 index 0000000..c546eaf --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/BaseUrlHolder.kt @@ -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(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/" + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/net/HostSelectionInterceptor.kt b/app/src/main/java/com/runicgateway/app/core/net/HostSelectionInterceptor.kt new file mode 100644 index 0000000..75ac13f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/HostSelectionInterceptor.kt @@ -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 +} diff --git a/app/src/main/java/com/runicgateway/app/core/net/ServerUrl.kt b/app/src/main/java/com/runicgateway/app/core/net/ServerUrl.kt new file mode 100644 index 0000000..6a4e0c4 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/ServerUrl.kt @@ -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+.-]*://") +} diff --git a/app/src/main/java/com/runicgateway/app/core/net/UserAgentInterceptor.kt b/app/src/main/java/com/runicgateway/app/core/net/UserAgentInterceptor.kt new file mode 100644 index 0000000..235c4b5 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/UserAgentInterceptor.kt @@ -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) + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/prefs/ServerPreferences.kt b/app/src/main/java/com/runicgateway/app/core/prefs/ServerPreferences.kt new file mode 100644 index 0000000..7f64d3e --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/prefs/ServerPreferences.kt @@ -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 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 = 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") + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt b/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt new file mode 100644 index 0000000..2afd018 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt @@ -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 { + data class Ok(val data: T) : ApiResult + data class HttpError(val status: Int, val message: String? = null) : ApiResult + data class NetworkError(val cause: Throwable) : ApiResult +} + +/** 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 ApiResult.map(transform: (T) -> R): ApiResult = 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 safeApiCall(block: suspend () -> T): ApiResult = try { + ApiResult.Ok(block()) +} catch (e: CancellationException) { + throw e +} catch (e: HttpException) { + ApiResult.HttpError(e.code(), e.message()) +} catch (e: IOException) { + ApiResult.NetworkError(e) +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt b/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt new file mode 100644 index 0000000..66ef64a --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt @@ -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 + + @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 + + @GET("api/v1/public/wiki/categories") + suspend fun getWikiCategories(): List + + @GET("api/v1/public/wiki/tags") + suspend fun getWikiTags(): List + + @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 +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/ContactDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/ContactDto.kt new file mode 100644 index 0000000..aafc58c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/ContactDto.kt @@ -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, +) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PageDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PageDto.kt new file mode 100644 index 0000000..164d5fd --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PageDto.kt @@ -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 = 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, +) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PostDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PostDto.kt new file mode 100644 index 0000000..0cdc2b8 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PostDto.kt @@ -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, +) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt new file mode 100644 index 0000000..eeff9f1 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt @@ -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(), +) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/WikiDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/WikiDto.kt new file mode 100644 index 0000000..3ad7785 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/WikiDto.kt @@ -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 = emptyList(), + val backlinks: List = emptyList(), + @SerialName("missing_links") val missingLinks: List = 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, +) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt new file mode 100644 index 0000000..35caa91 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt @@ -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" + } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ContactRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ContactRepository.kt new file mode 100644 index 0000000..f69ea5c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/ContactRepository.kt @@ -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 = + safeApiCall { api.postContact(ContactRequest(name = name, email = email, message = message)) } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ContentRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ContentRepository.kt new file mode 100644 index 0000000..1ad5096 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/ContentRepository.kt @@ -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> = + safeApiCall { api.getPosts(category.urlSlug) } + + suspend fun getPost(category: PostCategory, idOrSlug: String): ApiResult = + safeApiCall { api.getPost(category.urlSlug, idOrSlug) } + + suspend fun getPage(slug: String): ApiResult = + safeApiCall { api.getPage(slug) } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/SettingsRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/SettingsRepository.kt new file mode 100644 index 0000000..ade3239 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/SettingsRepository.kt @@ -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 = safeApiCall { api.getStatus() } + + suspend fun getSettings(): ApiResult = safeApiCall { api.getSettings() } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/WikiRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/WikiRepository.kt new file mode 100644 index 0000000..ba027c7 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/WikiRepository.kt @@ -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> = + safeApiCall { api.getWikiPages(query?.takeIf { it.isNotBlank() }, category, tag) } + + suspend fun getCategories(): ApiResult> = + safeApiCall { api.getWikiCategories() } + + suspend fun getTags(): ApiResult> = + safeApiCall { api.getWikiTags() } + + suspend fun getPage(slug: String): ApiResult = + safeApiCall { api.getWikiPage(slug) } +} diff --git a/app/src/main/java/com/runicgateway/app/di/AppModule.kt b/app/src/main/java/com/runicgateway/app/di/AppModule.kt new file mode 100644 index 0000000..1d2ccca --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/di/AppModule.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt new file mode 100644 index 0000000..54fd838 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -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) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt new file mode 100644 index 0000000..842cef6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt @@ -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.Loading) + val state: StateFlow = _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 + } +} + diff --git a/app/src/main/java/com/runicgateway/app/ui/LocalAssetResolver.kt b/app/src/main/java/com/runicgateway/app/ui/LocalAssetResolver.kt new file mode 100644 index 0000000..47a7bbe --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/LocalAssetResolver.kt @@ -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 } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt new file mode 100644 index 0000000..1a8bd21 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -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 + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/UiState.kt b/app/src/main/java/com/runicgateway/app/ui/UiState.kt new file mode 100644 index 0000000..abe0826 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/UiState.kt @@ -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>`; + * 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 { + data object Loading : UiState + data class Success(val data: T) : UiState + data class Error(val kind: ErrorKind, val httpStatus: Int? = null) : UiState +} + +/** + * 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 ApiResult.toUiState(): UiState = 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, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/components/HtmlText.kt b/app/src/main/java/com/runicgateway/app/ui/components/HtmlText.kt new file mode 100644 index 0000000..a4cb5c2 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/components/HtmlText.kt @@ -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) + }, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt b/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt new file mode 100644 index 0000000..6acdf1f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt @@ -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 +} diff --git a/app/src/main/java/com/runicgateway/app/ui/connect/ConnectScreen.kt b/app/src/main/java/com/runicgateway/app/ui/connect/ConnectScreen.kt new file mode 100644 index 0000000..75d7e91 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/connect/ConnectScreen.kt @@ -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) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/connect/ConnectViewModel.kt new file mode 100644 index 0000000..7645c76 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/connect/ConnectViewModel.kt @@ -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 = _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 + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/contact/ContactScreen.kt b/app/src/main/java/com/runicgateway/app/ui/contact/ContactScreen.kt new file mode 100644 index 0000000..35e085f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/contact/ContactScreen.kt @@ -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) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/contact/ContactViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/contact/ContactViewModel.kt new file mode 100644 index 0000000..33cc285 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/contact/ContactViewModel.kt @@ -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 = _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) + } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt b/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt new file mode 100644 index 0000000..b70e055 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt @@ -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), + ) + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..bb6f122 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/home/HomeViewModel.kt @@ -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.Loading) + val state: StateFlow> = _state.asStateFlow() + + init { load() } + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = settingsRepository.getStatus().toUiState() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt new file mode 100644 index 0000000..4a42bb4 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -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" +} diff --git a/app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt new file mode 100644 index 0000000..e3fdd2c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt @@ -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, + 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 +} diff --git a/app/src/main/java/com/runicgateway/app/ui/news/NewsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/news/NewsViewModel.kt new file mode 100644 index 0000000..c4107b3 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/news/NewsViewModel.kt @@ -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 = _category.asStateFlow() + + private val _state = MutableStateFlow>>(UiState.Loading) + val state: StateFlow>> = _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() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/news/PostScreen.kt b/app/src/main/java/com/runicgateway/app/ui/news/PostScreen.kt new file mode 100644 index 0000000..e24c688 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/news/PostScreen.kt @@ -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()) + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/news/PostViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/news/PostViewModel.kt new file mode 100644 index 0000000..5d8290f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/news/PostViewModel.kt @@ -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.Loading) + val state: StateFlow> = _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() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/page/BlockRenderer.kt b/app/src/main/java/com/runicgateway/app/ui/page/BlockRenderer.kt new file mode 100644 index 0000000..45b9dca --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/page/BlockRenderer.kt @@ -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, 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 { + val array = this[key] as? JsonArray ?: return emptyList() + return runCatching { + blockJson.decodeFromJsonElement(ListSerializer(BlockDto.serializer()), array) + }.getOrDefault(emptyList()) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/page/PageScreen.kt b/app/src/main/java/com/runicgateway/app/ui/page/PageScreen.kt new file mode 100644 index 0000000..082b854 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/page/PageScreen.kt @@ -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()) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/page/PageViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/page/PageViewModel.kt new file mode 100644 index 0000000..8565cd6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/page/PageViewModel.kt @@ -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.Loading) + val state: StateFlow> = _state.asStateFlow() + + init { load() } + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = contentRepository.getPage(slug).toUiState() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/BrandColor.kt b/app/src/main/java/com/runicgateway/app/ui/theme/BrandColor.kt new file mode 100644 index 0000000..ab95ce3 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/theme/BrandColor.kt @@ -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' diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt b/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt index 614b724..99f87fe 100644 --- a/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt @@ -3,15 +3,12 @@ */ package com.runicgateway.app.ui.theme -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.Color private val DarkColorScheme = darkColorScheme( primary = Purple80, @@ -26,23 +23,29 @@ private val LightColorScheme = lightColorScheme( ) /** - * App theme for the M0 skeleton. Dynamic color (Android 12+) is used when - * available; otherwise a static placeholder scheme. The M5 design pass wires - * the color scheme to per-shard branding (PLAN.md §3, §5). + * App theme for the functional pass. The color scheme is seeded from the + * per-shard brand accent (PLAN.md §3: the app themes itself from the site's + * branding) when one is available, so the app takes on each shard's color; it + * falls back to a neutral placeholder scheme before connect or when a site + * publishes no accent. + * + * This is deliberately a minimal seeding — a single-color override on the + * default Material 3 schemes. The M5 design pass replaces it with a full, + * designed color system (§2.1, §5); nothing here is meant to be the final look. */ @Composable fun RunicGatewayTheme( + accent: Color? = null, darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> DarkColorScheme - else -> LightColorScheme + val base = if (darkTheme) DarkColorScheme else LightColorScheme + val colorScheme = if (accent != null) { + // Brand consistency across a shard wins over device dynamic color: seed + // the primary role from the accent so buttons/highlights carry the brand. + base.copy(primary = accent, secondary = accent) + } else { + base } MaterialTheme( diff --git a/app/src/main/java/com/runicgateway/app/ui/wiki/WikiPageScreen.kt b/app/src/main/java/com/runicgateway/app/ui/wiki/WikiPageScreen.kt new file mode 100644 index 0000000..d43e48c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/wiki/WikiPageScreen.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/wiki/WikiPageViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/wiki/WikiPageViewModel.kt new file mode 100644 index 0000000..85074c1 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/wiki/WikiPageViewModel.kt @@ -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.Loading) + val state: StateFlow> = _state.asStateFlow() + + init { load() } + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = wikiRepository.getPage(slug).toUiState() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt b/app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt new file mode 100644 index 0000000..77810c9 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt @@ -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, 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), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/wiki/WikiViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/wiki/WikiViewModel.kt new file mode 100644 index 0000000..b3a89bc --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/wiki/WikiViewModel.kt @@ -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 = _query.asStateFlow() + + private val _state = MutableStateFlow>>(UiState.Loading) + val state: StateFlow>> = _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() + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index afc0ef1..4f6762f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,5 +8,69 @@ Runic Gateway - Runic Gateway — scaffold ready + + + Retry + Back + Can\'t reach the site. Check your connection and try again. + This content couldn\'t be found. + Too many requests. Please try again in a moment. + The shard is offline right now. + Something went wrong on the server. Please try again. + + + Connect to your shard + Enter your shard\'s website address to get started. + Website address + https://your-shard.example.com + Connect + Connecting… + Enter a website address. + That doesn\'t look like a valid web address. + Only http and https addresses are supported. + A secure https address is required. + That site isn\'t a Runic Gateway shard. + Couldn\'t reach that site. Check the address and your connection. + The site responded with an error (%1$d). Try again shortly. + + + Open navigation menu + Home + News + Wiki + About + Contact + Change server + + + Online + Under maintenance + Server %1$s · API %2$s + + + News & content + News + Screenshots + Five on Friday + Newsletter + No posts here yet. + + + Wiki + Search the wiki + No pages found. + Tags + Linked from + + + Contact + Your name + Your email + Message + Send message + Sending… + Message sent — thank you! + This site has no mailer configured. Email directly: %1$s + Please fill in every field. + Couldn\'t send your message. Please try again. diff --git a/app/src/test/java/com/runicgateway/app/core/net/HostRewriteTest.kt b/app/src/test/java/com/runicgateway/app/core/net/HostRewriteTest.kt new file mode 100644 index 0000000..c091d6f --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/net/HostRewriteTest.kt @@ -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", + ), + ) + } +} diff --git a/app/src/test/java/com/runicgateway/app/core/net/ServerUrlTest.kt b/app/src/test/java/com/runicgateway/app/core/net/ServerUrlTest.kt new file mode 100644 index 0000000..4b258bc --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/net/ServerUrlTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/com/runicgateway/app/core/result/ApiResultTest.kt b/app/src/test/java/com/runicgateway/app/core/result/ApiResultTest.kt new file mode 100644 index 0000000..69e4f65 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/result/ApiResultTest.kt @@ -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(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 = ApiResult.HttpError(500) + assertEquals(err, err.map { it * 2 }) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt b/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt new file mode 100644 index 0000000..13352c4 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt @@ -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 +} diff --git a/app/src/test/java/com/runicgateway/app/ui/theme/BrandColorTest.kt b/app/src/test/java/com/runicgateway/app/ui/theme/BrandColorTest.kt new file mode 100644 index 0000000..0d25bf3 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/theme/BrandColorTest.kt @@ -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")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f3d6920..072f917 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } # DI From 01481ef2d55ae114f9564a06c4d0e6359825f3cf Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sun, 19 Jul 2026 17:51:08 -0500 Subject: [PATCH 2/2] ci(android): raise Gradle heap + scope PR gate to debug variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The M1 PR-checks run hung ~16 min in `lintReportDebug` and was killed by the runner (33m35s, marked failure) — every compile/test/assemble task completed first; no task FAILED. Android lint's report phase needs more than the 2 GB heap and GC-thrashes to a hang below it on the full app codebase (it passed at 2 GB only while the M0 scaffold was trivial). - gradle.properties: -Xmx2048m → -Xmx3g, cap MaxMetaspaceSize=1g so the larger heap doesn't crowd container RAM. - pr-checks.yml: run `testDebugUnitTest lintDebug assembleDebug` instead of the aggregate `test lint assembleDebug`, so the release variant isn't compiled+linted in parallel — halving peak memory and build time while keeping the same coverage (unit tests are variant-agnostic). Verified locally with the exact command (`--no-daemon`); lintReportDebug + lintDebug run and pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr --- .gitea/workflows/pr-checks.yml | 6 +++++- gradle.properties | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 8917e43..b26a2b7 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -65,7 +65,11 @@ jobs: # chmod defensively: this runner's checkout doesn't preserve the git # executable bit, so `./gradlew` alone fails with "Permission denied". + # Debug-variant-only gate: unit tests, lint, and the debug APK. Scoping to + # the debug variant (vs. the aggregate `test`/`lint`) avoids compiling and + # linting the release variant in parallel, which halves peak memory on the + # runner and keeps lint's report phase from GC-thrashing (see gradle.properties). - name: Lint, test, assemble debug run: | chmod +x ./gradlew - ./gradlew --no-daemon lint test assembleDebug + ./gradlew --no-daemon testDebugUnitTest lintDebug assembleDebug diff --git a/gradle.properties b/gradle.properties index 2bbd399..e190010 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,9 @@ # Project-wide Gradle settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# Heap raised from 2048m: Android lint's report phase (lintReportDebug) needs +# more than 2 GB on the full app codebase and GC-thrashes to a hang below that +# on the CI runner (it passed at 2 GB only while the M0 scaffold was trivial). +# Metaspace is capped so the larger heap doesn't crowd container RAM. +org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 org.gradle.caching=true org.gradle.configuration-cache=true