git.iko.soy/opencode-android
on master help sign in
Add auto-reconnect, share-into-app, splash screen, and externalize all UI strings
commitd9842e3d44e6e0c8d005ba03fba564fbc4139f65 authoriko <mail@iko.soy> dateMon 29 Jun 2026, 11:11 (3 months ago) parent4743e50
26 files changed+402−129
app/build.gradle.kts+1 app/src/main/AndroidManifest.xml+9−4 app/src/main/java/soy/iko/opencode/MainActivity.kt+17 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+45 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+17−2 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+6−4 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+22−19 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+4−3 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+4−2 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+4−2 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+6−4 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+7−5 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+3−1 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+2−1 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+7−5 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+8−5 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+11−10 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+38−20 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+2−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+23−21 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+7−6 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+20−14 app/src/main/res/values/colors.xml+4 app/src/main/res/values/strings.xml+123 app/src/main/res/values/themes.xml+10 gradle/libs.versions.toml+2
app/build.gradle.kts+1
@@ -51,6 +51,7 @@ android {
5151
5252dependencies {
5353 implementation(libs.androidx.core.ktx)
54+ implementation(libs.androidx.core.splashscreen)
5455 implementation(libs.androidx.activity.compose)
5556 implementation(libs.androidx.lifecycle.viewmodel.compose)
5657 implementation(libs.androidx.lifecycle.runtime.compose)
app/src/main/AndroidManifest.xml+9−4
@@ -6,22 +6,27 @@
66
77 <application
88 android:name=".OpencodeApp"
9− android:allowBackup="true"
9+ android:allowBackup="false"
1010 android:label="@string/app_name"
1111 android:networkSecurityConfig="@xml/network_security_config"
1212 android:supportsRtl="true"
13− android:theme="@style/Theme.OpencodeAndroid"
14− android:usesCleartextTraffic="true">
13+ android:theme="@style/Theme.OpencodeAndroid">
1514
1615 <activity
1716 android:name=".MainActivity"
1817 android:exported="true"
1918 android:label="@string/app_name"
20− android:theme="@style/Theme.OpencodeAndroid">
19+ android:launchMode="singleTop"
20+ android:theme="@style/Theme.OpencodeAndroid.Splash">
2121 <intent-filter>
2222 <action android:name="android.intent.action.MAIN" />
2323 <category android:name="android.intent.category.LAUNCHER" />
2424 </intent-filter>
25+ <intent-filter>
26+ <action android:name="android.intent.action.SEND" />
27+ <category android:name="android.intent.category.DEFAULT" />
28+ <data android:mimeType="text/plain" />
29+ </intent-filter>
2530 </activity>
2631 </application>
2732
app/src/main/java/soy/iko/opencode/MainActivity.kt+17
@@ -1,11 +1,13 @@
11package soy.iko.opencode
22
3+import android.content.Intent
34import android.os.Bundle
45import androidx.activity.ComponentActivity
56import androidx.activity.compose.setContent
67import androidx.activity.enableEdgeToEdge
78import androidx.compose.foundation.isSystemInDarkTheme
89import androidx.compose.runtime.getValue
10+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
911import androidx.lifecycle.compose.collectAsStateWithLifecycle
1012import soy.iko.opencode.data.repo.ThemeMode
1113import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
@@ -13,9 +15,11 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
1315
1416class MainActivity : ComponentActivity() {
1517 override fun onCreate(savedInstanceState: Bundle?) {
18+ installSplashScreen()
1619 super.onCreate(savedInstanceState)
1720 enableEdgeToEdge()
1821 val container = (application as OpencodeApp).container
22+ if (savedInstanceState == null) handleShareIntent(intent)
1923 setContent {
2024 val themeMode by container.settingsStore.themeMode
2125 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
@@ -31,4 +35,17 @@ class MainActivity : ComponentActivity() {
3135 }
3236 }
3337 }
38+
39+ override fun onNewIntent(intent: Intent) {
40+ super.onNewIntent(intent)
41+ setIntent(intent)
42+ handleShareIntent(intent)
43+ }
44+
45+ /** Capture text shared from another app so it can be prefilled into a session draft. */
46+ private fun handleShareIntent(intent: Intent?) {
47+ if (intent?.action != Intent.ACTION_SEND) return
48+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.takeIf { it.isNotBlank() } ?: return
49+ (application as OpencodeApp).container.setPendingShare(text)
50+ }
3451}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+45
@@ -14,6 +14,9 @@ import kotlinx.coroutines.SupervisorJob
1414import kotlinx.coroutines.flow.MutableStateFlow
1515import kotlinx.coroutines.flow.StateFlow
1616import kotlinx.coroutines.flow.asStateFlow
17+import kotlinx.coroutines.flow.first
18+import kotlinx.coroutines.launch
19+import java.util.concurrent.atomic.AtomicBoolean
1720
1821/**
1922 * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
@@ -31,8 +34,50 @@ class AppContainer(context: Context) {
3134 private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
3235 val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
3336
37+ /** True while a background auto-reconnect to the most-recent server is in flight. */
38+ private val _reconnecting = MutableStateFlow(false)
39+ val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
40+
41+ /**
42+ * Set once a startup auto-reconnect succeeds. The server list collects this and
43+ * (once) navigates straight to the session list so a returning user skips it.
44+ * [consumeAutoConnect] guards against re-firing on screen re-entry.
45+ */
46+ private val _autoConnectDone = MutableStateFlow(false)
47+ val autoConnectDone: StateFlow<Boolean> = _autoConnectDone.asStateFlow()
48+ private val autoConnectConsumed = AtomicBoolean(false)
49+ fun consumeAutoConnect(): Boolean = autoConnectConsumed.compareAndSet(false, true)
50+
51+ /** Text shared into the app (ACTION_SEND), prefilled into a session draft. */
52+ private val _pendingShare = MutableStateFlow<String?>(null)
53+ val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
54+ fun setPendingShare(text: String) { _pendingShare.value = text }
55+ fun consumePendingShare(): String? = _pendingShare.value.also { _pendingShare.value = null }
56+
57+ /** Resolve a localized string — view models reach resources through the container. */
58+ fun string(id: Int, vararg formatArgs: Any): String =
59+ if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
60+
3461 init {
3562 registerNetworkMonitor()
63+ appScope.launch { autoConnect() }
64+ }
65+
66+ /**
67+ * On cold start, transparently reconnect to the most recently used server so a
68+ * returning user lands in their session list instead of the empty server screen.
69+ */
70+ private suspend fun autoConnect() {
71+ val recent = runCatching { profileStore.profiles.first() }
72+ .getOrDefault(emptyList())
73+ .firstOrNull() ?: return
74+ _reconnecting.value = true
75+ val ok = runCatching {
76+ val conn = connect(recent)
77+ conn.api.ping()
78+ }.isSuccess
79+ _reconnecting.value = false
80+ if (ok) _autoConnectDone.value = true else disconnect()
3681 }
3782
3883 /**
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+17−2
@@ -1,6 +1,7 @@
11package soy.iko.opencode.ui
22
33import androidx.compose.runtime.Composable
4+import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.getValue
56import androidx.navigation.NavType
67import androidx.navigation.compose.NavHost
@@ -20,7 +21,18 @@ import soy.iko.opencode.ui.settings.SettingsScreen
2021@Composable
2122fun OpencodeApp(container: AppContainer) {
2223 val navController = rememberNavController()
23− val active by container.activeConnection.collectAsStateWithLifecycle()
24+ val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
25+
26+ // When text is shared into the app, surface the session list so the user can pick
27+ // (or create) a conversation to drop it into. The chosen session's draft is set
28+ // in [onOpenSession] below. Fresh launches rely on auto-reconnect to get here.
29+ LaunchedEffect(pendingShare) {
30+ if (pendingShare == null) return@LaunchedEffect
31+ if (container.activeConnection.value == null) return@LaunchedEffect
32+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
33+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
34+ }
35+ }
2436
2537 NavHost(navController = navController, startDestination = Routes.SERVERS) {
2638
@@ -47,7 +59,10 @@ fun OpencodeApp(container: AppContainer) {
4759 composable(Routes.SESSIONS) {
4860 SessionListScreen(
4961 container = container,
50− onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
62+ onOpenSession = { id ->
63+ container.consumePendingShare()?.let { container.draftStore.set(id, it) }
64+ navController.navigate(Routes.chat(id))
65+ },
5166 onDisconnect = {
5267 container.disconnect()
5368 navController.popBackStack(Routes.SERVERS, inclusive = false)
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+6−4
@@ -13,8 +13,10 @@ import androidx.compose.material3.ModalBottomSheet
1313import androidx.compose.material3.Text
1414import androidx.compose.runtime.Composable
1515import androidx.compose.ui.Modifier
16+import androidx.compose.ui.res.stringResource
1617import androidx.compose.ui.unit.dp
1718import soy.iko.opencode.data.model.Agent
19+import soy.iko.opencode.R
1820
1921/** Bottom sheet that lists every agent and lets the user pick one. */
2022@OptIn(ExperimentalMaterial3Api::class)
@@ -27,13 +29,13 @@ fun AgentPickerSheet(
2729) {
2830 ModalBottomSheet(onDismissRequest = onDismiss) {
2931 Text(
30− "Agent",
32+ stringResource(R.string.agent),
3133 style = MaterialTheme.typography.titleMedium,
3234 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
3335 )
3436 if (agents.isEmpty()) {
3537 Text(
36− "No agents reported by the server.",
38+ stringResource(R.string.no_agents),
3739 style = MaterialTheme.typography.bodyMedium,
3840 color = MaterialTheme.colorScheme.onSurfaceVariant,
3941 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
@@ -48,12 +50,12 @@ fun AgentPickerSheet(
4850 .padding(horizontal = 24.dp, vertical = 12.dp),
4951 ) {
5052 Text(
51− "Default",
53+ stringResource(R.string.default_agent),
5254 style = MaterialTheme.typography.bodyLarge,
5355 color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
5456 )
5557 Text(
56− "Server default agent" + if (selected == null) " ✓" else "",
58+ stringResource(R.string.default_agent_desc) + if (selected == null) " ✓" else "",
5759 style = MaterialTheme.typography.bodySmall,
5860 color = MaterialTheme.colorScheme.onSurfaceVariant,
5961 )
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+22−19
@@ -57,12 +57,14 @@ import androidx.compose.ui.input.key.key
5757import androidx.compose.ui.input.key.onPreviewKeyEvent
5858import androidx.compose.ui.input.key.type
5959import androidx.compose.ui.platform.LocalHapticFeedback
60+import androidx.compose.ui.res.stringResource
6061import androidx.compose.ui.unit.dp
6162import androidx.lifecycle.compose.collectAsStateWithLifecycle
6263import androidx.lifecycle.viewmodel.compose.viewModel
6364import kotlinx.coroutines.launch
6465import soy.iko.opencode.data.network.EventStreamClient
6566import soy.iko.opencode.di.AppContainer
67+import soy.iko.opencode.R
6668import soy.iko.opencode.ui.vmFactory
6769
6870@OptIn(ExperimentalMaterial3Api::class)
@@ -111,10 +113,11 @@ fun ChatScreen(
111113 listState.animateScrollToItem(messages.lastIndex)
112114 }
113115 }
116+ val retryLabel = stringResource(R.string.retry)
114117 LaunchedEffect(error) {
115118 val msg = error ?: return@LaunchedEffect
116119 val result = if (failedDraft != null) {
117− snackbar.showSnackbar(message = msg, actionLabel = "Retry")
120+ snackbar.showSnackbar(message = msg, actionLabel = retryLabel)
118121 } else {
119122 snackbar.showSnackbar(msg)
120123 }
@@ -135,10 +138,10 @@ fun ChatScreen(
135138 TopAppBar(
136139 title = {
137140 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
138− Text(sessionTitle ?: "Session")
141+ Text(sessionTitle ?: stringResource(R.string.session))
139142 Text(
140143 buildString {
141− append(selectedModel?.modelLabel ?: "default model")
144+ append(selectedModel?.modelLabel ?: stringResource(R.string.default_model))
142145 selectedAgent?.let { append(" • $it") }
143146 },
144147 style = MaterialTheme.typography.labelSmall,
@@ -150,18 +153,18 @@ fun ChatScreen(
150153 IconButton(onClick = {
151154 if (running) showExitConfirm = true else onBack()
152155 }) {
153− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
156+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
154157 }
155158 },
156159 actions = {
157160 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
158− Icon(Icons.Filled.Terminal, contentDescription = "Commands")
161+ Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
159162 }
160163 IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
161− Icon(Icons.Filled.SmartToy, contentDescription = "Choose agent")
164+ Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
162165 }
163166 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
164− Icon(Icons.Filled.Tune, contentDescription = "Choose model")
167+ Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
165168 }
166169 },
167170 )
@@ -181,7 +184,7 @@ fun ChatScreen(
181184 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
182185 if (!vm.connected) {
183186 Text(
184− "Not connected.",
187+ stringResource(R.string.not_connected),
185188 modifier = Modifier.align(Alignment.Center).padding(24.dp),
186189 color = MaterialTheme.colorScheme.error,
187190 )
@@ -203,7 +206,7 @@ fun ChatScreen(
203206 item(key = "__typing") {
204207 Row(verticalAlignment = Alignment.CenterVertically) {
205208 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
206− Text(" working…", style = MaterialTheme.typography.labelMedium)
209+ Text(" " + stringResource(R.string.working), style = MaterialTheme.typography.labelMedium)
207210 }
208211 }
209212 }
@@ -220,7 +223,7 @@ fun ChatScreen(
220223 }
221224 },
222225 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
223− text = { Text("Latest") },
226+ text = { Text(stringResource(R.string.latest)) },
224227 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
225228 )
226229 }
@@ -231,17 +234,17 @@ fun ChatScreen(
231234 if (showExitConfirm) {
232235 AlertDialog(
233236 onDismissRequest = { showExitConfirm = false },
234− title = { Text("Stop and exit?") },
235− text = { Text("The agent is still working. Exit anyway?") },
237+ title = { Text(stringResource(R.string.stop_and_exit_title)) },
238+ text = { Text(stringResource(R.string.stop_and_exit_text)) },
236239 confirmButton = {
237240 TextButton(onClick = {
238241 showExitConfirm = false
239242 vm.abort()
240243 onBack()
241− }) { Text("Stop & exit") }
244+ }) { Text(stringResource(R.string.stop_and_exit)) }
242245 },
243246 dismissButton = {
244− TextButton(onClick = { showExitConfirm = false }) { Text("Stay") }
247+ TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
245248 },
246249 )
247250 }
@@ -286,8 +289,8 @@ private fun ConnectionBanner(
286289 modifier: Modifier = Modifier,
287290) {
288291 val text = when (state) {
289− EventStreamClient.ConnectionState.Connecting -> "Connecting…"
290− EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
292+ EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
293+ EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
291294 EventStreamClient.ConnectionState.Connected -> null
292295 }
293296 if (text != null) {
@@ -342,13 +345,13 @@ private fun ChatInputBar(
342345 false
343346 }
344347 },
345− placeholder = { Text("Message opencode…") },
348+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
346349 enabled = enabled,
347350 maxLines = 6,
348351 )
349352 if (running) {
350353 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
351− Icon(Icons.Filled.Stop, contentDescription = "Stop")
354+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
352355 }
353356 } else {
354357 IconButton(
@@ -356,7 +359,7 @@ private fun ChatInputBar(
356359 enabled = enabled && value.isNotBlank(),
357360 modifier = Modifier.padding(start = 4.dp),
358361 ) {
359− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
362+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
360363 }
361364 }
362365 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+4−3
@@ -16,6 +16,7 @@ import soy.iko.opencode.data.model.toOptions
1616import soy.iko.opencode.data.network.EventStreamClient
1717import soy.iko.opencode.data.repo.SessionRepository
1818import soy.iko.opencode.di.AppContainer
19+import soy.iko.opencode.R
1920import kotlinx.coroutines.flow.MutableStateFlow
2021import kotlinx.coroutines.flow.SharingStarted
2122import kotlinx.coroutines.flow.StateFlow
@@ -100,7 +101,7 @@ class ChatViewModel(
100101 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
101102 if (SessionRepository.isError(event, sessionId)) {
102103 _running.value = false
103− _error.value = "The agent reported an error."
104+ _error.value = container.string(R.string.error_agent_reported)
104105 }
105106 when (event) {
106107 is PermissionUpdated ->
@@ -160,7 +161,7 @@ class ChatViewModel(
160161 if (!ok) {
161162 _failedDraft.value = trimmed
162163 updateDraft(trimmed)
163− _error.value = "Failed to send"
164+ _error.value = container.string(R.string.error_failed_to_send)
164165 }
165166 }
166167 return true
@@ -182,7 +183,7 @@ class ChatViewModel(
182183 viewModelScope.launch {
183184 runCatching {
184185 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
185− }.onFailure { _error.value = it.message ?: "Failed to run command" }
186+ }.onFailure { _error.value = it.message ?: container.string(R.string.error_failed_command) }
186187 _running.value = false
187188 }
188189 }
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+4−2
@@ -16,9 +16,11 @@ import androidx.compose.material3.ModalBottomSheet
1616import androidx.compose.material3.Text
1717import androidx.compose.runtime.Composable
1818import androidx.compose.ui.Modifier
19+import androidx.compose.ui.res.stringResource
1920import androidx.compose.ui.text.style.TextOverflow
2021import androidx.compose.ui.unit.dp
2122import soy.iko.opencode.data.model.Command
23+import soy.iko.opencode.R
2224
2325/** Bottom sheet that lists slash-commands; selecting one invokes it. */
2426@OptIn(ExperimentalMaterial3Api::class)
@@ -30,13 +32,13 @@ fun CommandPickerSheet(
3032) {
3133 ModalBottomSheet(onDismissRequest = onDismiss) {
3234 Text(
33− "Commands",
35+ stringResource(R.string.commands),
3436 style = MaterialTheme.typography.titleMedium,
3537 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
3638 )
3739 if (commands.isEmpty()) {
3840 Text(
39− "No commands reported by the server.",
41+ stringResource(R.string.no_commands),
4042 style = MaterialTheme.typography.bodyMedium,
4143 color = MaterialTheme.colorScheme.onSurfaceVariant,
4244 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+4−2
@@ -13,8 +13,10 @@ import androidx.compose.material3.ModalBottomSheet
1313import androidx.compose.material3.Text
1414import androidx.compose.runtime.Composable
1515import androidx.compose.ui.Modifier
16+import androidx.compose.ui.res.stringResource
1617import androidx.compose.ui.unit.dp
1718import soy.iko.opencode.data.model.ModelOption
19+import soy.iko.opencode.R
1820
1921/** Bottom sheet that lists every provider/model and lets the user pick one. */
2022@OptIn(ExperimentalMaterial3Api::class)
@@ -27,13 +29,13 @@ fun ModelPickerSheet(
2729) {
2830 ModalBottomSheet(onDismissRequest = onDismiss) {
2931 Text(
30− "Model",
32+ stringResource(R.string.model),
3133 style = MaterialTheme.typography.titleMedium,
3234 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
3335 )
3436 if (options.isEmpty()) {
3537 Text(
36− "No models reported by the server.",
38+ stringResource(R.string.no_models),
3739 style = MaterialTheme.typography.bodyMedium,
3840 color = MaterialTheme.colorScheme.onSurfaceVariant,
3941 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+6−4
@@ -33,6 +33,7 @@ import androidx.compose.ui.Modifier
3333import androidx.compose.ui.draw.clip
3434import androidx.compose.ui.platform.LocalContext
3535import androidx.compose.ui.text.font.FontFamily
36+import androidx.compose.ui.res.stringResource
3637import androidx.compose.ui.unit.dp
3738import soy.iko.opencode.data.model.FilePart
3839import soy.iko.opencode.data.model.Part
@@ -52,6 +53,7 @@ import soy.iko.opencode.ui.components.DiffView
5253import soy.iko.opencode.ui.components.MarkdownText
5354import soy.iko.opencode.ui.components.copyToClipboard
5455import soy.iko.opencode.ui.components.looksLikeDiff
56+import soy.iko.opencode.R
5557
5658private const val COLLAPSED_LIMIT = 600
5759
@@ -99,7 +101,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
99101 )
100102 }
101103 Text(
102− " ${if (streaming) "Thinking…" else "Thoughts"}",
104+ " ${if (streaming) stringResource(R.string.thinking) else stringResource(R.string.thoughts)}",
103105 style = MaterialTheme.typography.labelMedium,
104106 color = MaterialTheme.colorScheme.onSurfaceVariant,
105107 )
@@ -116,7 +118,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
116118 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
117119 ) {
118120 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
119− Text(" Copy", style = MaterialTheme.typography.labelSmall)
121+ Text(" " + stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall)
120122 }
121123 }
122124 }
@@ -171,7 +173,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
171173 modifier = Modifier.size(16.dp),
172174 )
173175 Text(
174− " ${if (expanded) "Show less" else "Show more"}",
176+ " ${if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more)}",
175177 style = MaterialTheme.typography.labelSmall,
176178 )
177179 }
@@ -181,7 +183,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
181183 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
182184 ) {
183185 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
184− Text(" Copy", style = MaterialTheme.typography.labelSmall)
186+ Text(" " + stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall)
185187 }
186188 }
187189 }
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+7−5
@@ -17,10 +17,12 @@ import androidx.compose.material3.TextButton
1717import androidx.compose.runtime.Composable
1818import androidx.compose.ui.Modifier
1919import androidx.compose.ui.platform.LocalHapticFeedback
20+import androidx.compose.ui.res.stringResource
2021import androidx.compose.ui.text.font.FontFamily
2122import androidx.compose.ui.unit.dp
2223import soy.iko.opencode.data.model.Permission
2324import soy.iko.opencode.data.model.PermissionResponse
25+import soy.iko.opencode.R
2426
2527/**
2628 * Modal asking the user to approve a tool the agent wants to run. The agent run is
@@ -36,7 +38,7 @@ fun PermissionDialog(
3638 AlertDialog(
3739 onDismissRequest = { /* require an explicit choice */ },
3840 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
39− title = { Text(permission.title ?: "Permission requested") },
41+ title = { Text(permission.title ?: stringResource(R.string.permission_title)) },
4042 text = {
4143 Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
4244 permission.type?.let {
@@ -51,7 +53,7 @@ fun PermissionDialog(
5153 )
5254 }
5355 Text(
54− "Allow this tool to run?",
56+ stringResource(R.string.permission_text),
5557 style = MaterialTheme.typography.bodySmall,
5658 color = MaterialTheme.colorScheme.onSurfaceVariant,
5759 )
@@ -68,14 +70,14 @@ fun PermissionDialog(
6870 onRespond(PermissionResponse.ONCE)
6971 },
7072 modifier = Modifier.fillMaxWidth(),
71− ) { Text("Allow once") }
73+ ) { Text(stringResource(R.string.allow_once)) }
7274 OutlinedButton(
7375 onClick = {
7476 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
7577 onRespond(PermissionResponse.ALWAYS)
7678 },
7779 modifier = Modifier.fillMaxWidth(),
78− ) { Text("Always allow") }
80+ ) { Text(stringResource(R.string.always_allow)) }
7981 }
8082 },
8183 dismissButton = {
@@ -83,7 +85,7 @@ fun PermissionDialog(
8385 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
8486 onRespond(PermissionResponse.REJECT)
8587 }) {
86− Text("Reject", color = MaterialTheme.colorScheme.error)
88+ Text(stringResource(R.string.reject), color = MaterialTheme.colorScheme.error)
8789 }
8890 },
8991 )
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+3−1
@@ -26,6 +26,8 @@ import androidx.compose.ui.graphics.Color
2626import androidx.compose.ui.platform.LocalContext
2727import androidx.compose.ui.text.font.FontFamily
2828import androidx.compose.ui.unit.dp
29+import androidx.compose.ui.res.stringResource
30+import soy.iko.opencode.R
2931
3032/** A single line of a parsed unified diff. */
3133sealed interface DiffLine {
@@ -91,7 +93,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
9193 ) {
9294 Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
9395 IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
94− Icon(Icons.Filled.ContentCopy, contentDescription = "Copy", tint = MaterialTheme.colorScheme.onSurfaceVariant)
96+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
9597 }
9698 }
9799 Column(
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+2−1
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
1212import androidx.compose.ui.platform.LocalContext
1313import androidx.compose.ui.text.TextStyle
1414import com.mikepenz.markdown.m3.Markdown
15+import soy.iko.opencode.R
1516
1617/**
1718 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
@@ -39,5 +40,5 @@ fun MarkdownText(
3940internal fun copyToClipboard(context: Context, label: String, text: String = label) {
4041 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
4142 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
42− Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
43+ Toast.makeText(context, context.getString(R.string.copied), Toast.LENGTH_SHORT).show()
4344}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+7−5
@@ -37,10 +37,12 @@ import androidx.compose.ui.graphics.Color
3737import androidx.compose.ui.text.font.FontFamily
3838import androidx.compose.ui.text.style.TextOverflow
3939import androidx.compose.ui.unit.dp
40+import androidx.compose.ui.res.stringResource
4041import androidx.lifecycle.compose.collectAsStateWithLifecycle
4142import androidx.lifecycle.viewmodel.compose.viewModel
4243import soy.iko.opencode.data.model.FileStatusEntry
4344import soy.iko.opencode.di.AppContainer
45+import soy.iko.opencode.R
4446import soy.iko.opencode.ui.vmFactory
4547
4648@OptIn(ExperimentalMaterial3Api::class)
@@ -56,10 +58,10 @@ fun FileBrowserScreen(
5658 Scaffold(
5759 topBar = {
5860 TopAppBar(
59− title = { Text("Files", maxLines = 1, overflow = TextOverflow.Ellipsis) },
61+ title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
6062 navigationIcon = {
6163 IconButton(onClick = onBack) {
62− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
64+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
6365 }
6466 },
6567 )
@@ -76,7 +78,7 @@ fun FileBrowserScreen(
7678 value = state.query,
7779 onValueChange = vm::setQuery,
7880 modifier = Modifier.fillMaxWidth().padding(12.dp),
79− placeholder = { Text("Search files…") },
81+ placeholder = { Text(stringResource(R.string.search_files)) },
8082 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
8183 singleLine = true,
8284 )
@@ -112,7 +114,7 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
112114 verticalAlignment = Alignment.CenterVertically,
113115 ) {
114116 IconButton(onClick = { onNavigate("") }, modifier = Modifier.size(20.dp)) {
115− Icon(Icons.Filled.Home, contentDescription = "Root", modifier = Modifier.size(18.dp))
117+ Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(18.dp))
116118 }
117119 var acc = ""
118120 segments.forEachIndexed { index, segment ->
@@ -137,7 +139,7 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
137139@Composable
138140private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
139141 if (results.isEmpty()) {
140− Text("No matches.", modifier = Modifier.padding(24.dp))
142+ Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
141143 return
142144 }
143145 LazyColumn(modifier = Modifier.fillMaxSize()) {
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+8−5
@@ -33,9 +33,11 @@ import androidx.compose.ui.platform.LocalContext
3333import androidx.compose.ui.text.font.FontFamily
3434import androidx.compose.ui.text.style.TextOverflow
3535import androidx.compose.ui.unit.dp
36+import androidx.compose.ui.res.stringResource
3637import androidx.lifecycle.compose.collectAsStateWithLifecycle
3738import androidx.lifecycle.viewmodel.compose.viewModel
3839import soy.iko.opencode.di.AppContainer
40+import soy.iko.opencode.R
3941import soy.iko.opencode.ui.components.DiffView
4042import soy.iko.opencode.ui.components.copyToClipboard
4143import soy.iko.opencode.ui.vmFactory
@@ -51,6 +53,7 @@ fun FileViewScreen(
5153 val state by vm.state.collectAsStateWithLifecycle()
5254 val context = LocalContext.current
5355 val filename = remember(path) { path.substringAfterLast('/') }
56+ val shareLabel = stringResource(R.string.share)
5457
5558 Scaffold(
5659 topBar = {
@@ -58,14 +61,14 @@ fun FileViewScreen(
5861 title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
5962 navigationIcon = {
6063 IconButton(onClick = onBack) {
61− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
64+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
6265 }
6366 },
6467 actions = {
6568 val content = state.content?.content.orEmpty()
6669 if (content.isNotEmpty()) {
6770 IconButton(onClick = { copyToClipboard(context, filename, content) }) {
68− Icon(Icons.Filled.ContentCopy, contentDescription = "Copy")
71+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
6972 }
7073 IconButton(onClick = {
7174 val send = Intent(Intent.ACTION_SEND).apply {
@@ -73,9 +76,9 @@ fun FileViewScreen(
7376 putExtra(Intent.EXTRA_SUBJECT, filename)
7477 putExtra(Intent.EXTRA_TEXT, content)
7578 }
76− context.startActivity(Intent.createChooser(send, "Share $filename"))
79+ context.startActivity(Intent.createChooser(send, shareLabel))
7780 }) {
78− Icon(Icons.Filled.Share, contentDescription = "Share")
81+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
7982 }
8083 }
8184 },
@@ -91,7 +94,7 @@ fun FileViewScreen(
9194 color = MaterialTheme.colorScheme.error,
9295 )
9396 state.content?.isBinary == true -> Text(
94− "Binary file — preview not supported.",
97+ stringResource(R.string.binary_file),
9598 modifier = Modifier.align(Alignment.Center).padding(24.dp),
9699 color = MaterialTheme.colorScheme.onSurfaceVariant,
97100 )
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+11−10
@@ -22,10 +22,12 @@ import androidx.compose.ui.text.input.KeyboardType
2222import androidx.compose.ui.text.input.PasswordVisualTransformation
2323import androidx.compose.foundation.text.KeyboardOptions
2424import androidx.compose.foundation.layout.Arrangement
25+import androidx.compose.ui.res.stringResource
2526import androidx.compose.ui.unit.dp
2627import androidx.lifecycle.compose.collectAsStateWithLifecycle
2728import androidx.lifecycle.viewmodel.compose.viewModel
2829import soy.iko.opencode.di.AppContainer
30+import soy.iko.opencode.R
2931import soy.iko.opencode.ui.vmFactory
3032
3133@OptIn(ExperimentalMaterial3Api::class)
@@ -42,10 +44,10 @@ fun ServerEditScreen(
4244 Scaffold(
4345 topBar = {
4446 TopAppBar(
45− title = { Text(if (state.isNew) "Add server" else "Edit server") },
47+ title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
4648 navigationIcon = {
4749 IconButton(onClick = onDone) {
48− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
50+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
4951 }
5052 },
5153 )
@@ -61,35 +63,34 @@ fun ServerEditScreen(
6163 OutlinedTextField(
6264 value = state.label,
6365 onValueChange = { v -> vm.update { it.copy(label = v) } },
64− label = { Text("Label (optional)") },
66+ label = { Text(stringResource(R.string.label_optional)) },
6567 singleLine = true,
6668 modifier = Modifier.fillMaxWidth(),
6769 )
6870 OutlinedTextField(
6971 value = state.baseUrl,
7072 onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
71− label = { Text("Base URL") },
72− placeholder = { Text("http://192.168.1.10:4096") },
73+ label = { Text(stringResource(R.string.base_url)) },
74+ placeholder = { Text(stringResource(R.string.base_url_hint)) },
7375 singleLine = true,
7476 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
7577 modifier = Modifier.fillMaxWidth(),
7678 )
7779 Text(
78− "Auth is optional — only needed if the server runs with OPENCODE_SERVER_PASSWORD. " +
79− "Username defaults to \"opencode\".",
80+ stringResource(R.string.auth_help),
8081 style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
8182 )
8283 OutlinedTextField(
8384 value = state.username,
8485 onValueChange = { v -> vm.update { it.copy(username = v) } },
85− label = { Text("Username (optional)") },
86+ label = { Text(stringResource(R.string.username_optional)) },
8687 singleLine = true,
8788 modifier = Modifier.fillMaxWidth(),
8889 )
8990 OutlinedTextField(
9091 value = state.password,
9192 onValueChange = { v -> vm.update { it.copy(password = v) } },
92− label = { Text("Password (optional)") },
93+ label = { Text(stringResource(R.string.password_optional)) },
9394 singleLine = true,
9495 visualTransformation = PasswordVisualTransformation(),
9596 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
@@ -100,7 +101,7 @@ fun ServerEditScreen(
100101 enabled = state.canSave,
101102 modifier = Modifier.fillMaxWidth(),
102103 ) {
103− Text("Save")
104+ Text(stringResource(R.string.save))
104105 }
105106 }
106107 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+38−20
@@ -25,6 +25,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
2525import androidx.compose.material3.FloatingActionButton
2626import androidx.compose.material3.Icon
2727import androidx.compose.material3.IconButton
28+import androidx.compose.material3.LinearProgressIndicator
2829import androidx.compose.material3.MaterialTheme
2930import androidx.compose.material3.Scaffold
3031import androidx.compose.material3.SnackbarHost
@@ -43,10 +44,12 @@ import androidx.compose.ui.Modifier
4344import androidx.compose.ui.platform.LocalHapticFeedback
4445import androidx.compose.ui.text.style.TextOverflow
4546import androidx.compose.ui.unit.dp
47+import androidx.compose.ui.res.stringResource
4648import androidx.lifecycle.compose.collectAsStateWithLifecycle
4749import androidx.lifecycle.viewmodel.compose.viewModel
4850import soy.iko.opencode.data.model.ServerProfile
4951import soy.iko.opencode.di.AppContainer
52+import soy.iko.opencode.R
5053import soy.iko.opencode.ui.vmFactory
5154
5255@OptIn(ExperimentalMaterial3Api::class)
@@ -62,6 +65,7 @@ fun ServerListScreen(
6265 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
6366 val error by vm.error.collectAsStateWithLifecycle()
6467 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
68+ val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
6569 val haptics = LocalHapticFeedback.current
6670 val snackbar = remember { SnackbarHostState() }
6771 var pendingDelete by remember { mutableStateOf<ServerProfile?>(null) }
@@ -74,21 +78,34 @@ fun ServerListScreen(
7478 }
7579 }
7680
81+ // On cold start the container auto-reconnects to the last server; once it lands,
82+ // skip this screen and go straight to the session list. Consumed once so a later
83+ // manual visit to this screen doesn't bounce the user back out.
84+ LaunchedEffect(Unit) {
85+ container.autoConnectDone.collect { succeeded ->
86+ if (succeeded && container.consumeAutoConnect()) onConnected()
87+ }
88+ }
89+
7790 Scaffold(
78− topBar = { TopAppBar(title = { Text("opencode servers") }) },
91+ topBar = { TopAppBar(title = { Text(stringResource(R.string.servers_title)) }) },
7992 snackbarHost = { SnackbarHost(snackbar) },
8093 floatingActionButton = {
8194 FloatingActionButton(onClick = onAddProfile) {
82− Icon(Icons.Filled.Add, contentDescription = "Add server")
95+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_server))
8396 }
8497 },
8598 ) { padding ->
86− if (profiles.isEmpty()) {
87− EmptyServers(
88− onAdd = onAddProfile,
89− modifier = Modifier.fillMaxSize().padding(padding),
90− )
91− } else {
99+ Column(modifier = Modifier.fillMaxSize()) {
100+ if (reconnecting && profiles.isNotEmpty()) {
101+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
102+ }
103+ if (profiles.isEmpty()) {
104+ EmptyServers(
105+ onAdd = onAddProfile,
106+ modifier = Modifier.fillMaxSize().padding(padding),
107+ )
108+ } else {
92109 LazyColumn(
93110 modifier = Modifier.fillMaxSize().padding(padding),
94111 contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
@@ -115,7 +132,7 @@ fun ServerListScreen(
115132 overflow = TextOverflow.Ellipsis,
116133 )
117134 Text(
118− profile.baseUrl + if (profile.hasAuth) " • auth" else "",
135+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
119136 style = MaterialTheme.typography.bodySmall,
120137 color = MaterialTheme.colorScheme.onSurfaceVariant,
121138 maxLines = 1,
@@ -123,7 +140,7 @@ fun ServerListScreen(
123140 )
124141 if (isActive) {
125142 Text(
126− "Connected",
143+ stringResource(R.string.connected),
127144 style = MaterialTheme.typography.labelSmall,
128145 color = MaterialTheme.colorScheme.primary,
129146 )
@@ -132,7 +149,7 @@ fun ServerListScreen(
132149 if (isActive) {
133150 Icon(
134151 Icons.Filled.CheckCircle,
135− contentDescription = "Connected",
152+ contentDescription = stringResource(R.string.connected),
136153 tint = MaterialTheme.colorScheme.primary,
137154 modifier = Modifier.size(22.dp),
138155 )
@@ -140,33 +157,34 @@ fun ServerListScreen(
140157 CircularProgressIndicator(modifier = Modifier.size(24.dp))
141158 } else {
142159 IconButton(onClick = { onEditProfile(profile.id) }) {
143− Icon(Icons.Filled.Edit, contentDescription = "Edit")
160+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
144161 }
145162 IconButton(onClick = { pendingDelete = profile }) {
146− Icon(Icons.Filled.Delete, contentDescription = "Delete")
163+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
147164 }
148165 }
149166 }
150167 }
151168 }
152169 }
170+ }
153171 }
154172 }
155173
156174 pendingDelete?.let { profile ->
157175 AlertDialog(
158176 onDismissRequest = { pendingDelete = null },
159− title = { Text("Remove server?") },
160− text = { Text("“${profile.displayLabel}” will be removed from your saved servers.") },
177+ title = { Text(stringResource(R.string.remove_server_title)) },
178+ text = { Text(stringResource(R.string.remove_server_text, profile.displayLabel)) },
161179 confirmButton = {
162180 TextButton(onClick = {
163181 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
164182 pendingDelete = null
165183 vm.delete(profile)
166− }) { Text("Remove", color = MaterialTheme.colorScheme.error) }
184+ }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
167185 },
168186 dismissButton = {
169− TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
187+ TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
170188 },
171189 )
172190 }
@@ -186,17 +204,17 @@ private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
186204 tint = MaterialTheme.colorScheme.onSurfaceVariant,
187205 )
188206 Spacer(Modifier.size(16.dp))
189− Text("No servers yet", style = MaterialTheme.typography.titleMedium)
207+ Text(stringResource(R.string.no_servers_yet), style = MaterialTheme.typography.titleMedium)
190208 Spacer(Modifier.size(8.dp))
191209 Text(
192− "Add an opencode serve endpoint to connect.",
210+ stringResource(R.string.no_servers_hint),
193211 style = MaterialTheme.typography.bodyMedium,
194212 color = MaterialTheme.colorScheme.onSurfaceVariant,
195213 )
196214 Spacer(Modifier.size(20.dp))
197215 TextButton(onClick = onAdd) {
198216 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
199− Text(" Add server")
217+ Text(" " + stringResource(R.string.add_server))
200218 }
201219 }
202220 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+2−1
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import soy.iko.opencode.data.model.ServerProfile
66import soy.iko.opencode.di.AppContainer
7+import soy.iko.opencode.R
78import kotlinx.coroutines.flow.MutableStateFlow
89import kotlinx.coroutines.flow.SharingStarted
910import kotlinx.coroutines.flow.StateFlow
@@ -41,7 +42,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
4142 result.onSuccess { onConnected() }
4243 .onFailure {
4344 container.disconnect()
44− _error.value = it.message ?: "Could not reach ${profile.baseUrl}"
45+ _error.value = it.message ?: container.string(R.string.error_not_reachable, profile.baseUrl)
4546 }
4647 }
4748 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+23−21
@@ -50,10 +50,12 @@ import androidx.compose.ui.Modifier
5050import androidx.compose.ui.platform.LocalHapticFeedback
5151import androidx.compose.ui.text.style.TextOverflow
5252import androidx.compose.ui.unit.dp
53+import androidx.compose.ui.res.stringResource
5354import androidx.lifecycle.compose.collectAsStateWithLifecycle
5455import androidx.lifecycle.viewmodel.compose.viewModel
5556import soy.iko.opencode.data.model.Session
5657import soy.iko.opencode.di.AppContainer
58+import soy.iko.opencode.R
5759import soy.iko.opencode.ui.components.relativeTime
5860import soy.iko.opencode.ui.vmFactory
5961
@@ -87,7 +89,7 @@ fun SessionListScreen(
8789 verticalAlignment = Alignment.CenterVertically,
8890 ) {
8991 Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
90− Icon(Icons.Filled.ArrowDropDown, contentDescription = "Switch server")
92+ Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.switch_server))
9193 }
9294 DropdownMenu(
9395 expanded = showServerMenu,
@@ -129,16 +131,16 @@ fun SessionListScreen(
129131 },
130132 actions = {
131133 IconButton(onClick = { vm.refresh() }) {
132− Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
134+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
133135 }
134136 IconButton(onClick = onOpenFiles) {
135− Icon(Icons.Filled.Folder, contentDescription = "Files")
137+ Icon(Icons.Filled.Folder, contentDescription = stringResource(R.string.files))
136138 }
137139 IconButton(onClick = onOpenSettings) {
138− Icon(Icons.Filled.Settings, contentDescription = "Settings")
140+ Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
139141 }
140142 IconButton(onClick = onDisconnect) {
141− Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Disconnect")
143+ Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.disconnect))
142144 }
143145 },
144146 )
@@ -147,7 +149,7 @@ fun SessionListScreen(
147149 ExtendedFloatingActionButton(
148150 onClick = { vm.createSession(onCreated = onOpenSession) },
149151 icon = { Icon(Icons.Filled.Add, contentDescription = null) },
150− text = { Text("New session") },
152+ text = { Text(stringResource(R.string.new_session)) },
151153 )
152154 },
153155 ) { padding ->
@@ -168,14 +170,14 @@ fun SessionListScreen(
168170 value = state.query,
169171 onValueChange = vm::setQuery,
170172 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
171− placeholder = { Text("Search sessions…") },
173+ placeholder = { Text(stringResource(R.string.search_sessions)) },
172174 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
173175 singleLine = true,
174176 )
175177 val sessions = state.filtered
176178 if (sessions.isEmpty()) {
177179 Text(
178− "No sessions match “${state.query}”.",
180+ stringResource(R.string.no_sessions_match, state.query),
179181 modifier = Modifier.padding(24.dp),
180182 color = MaterialTheme.colorScheme.onSurfaceVariant,
181183 )
@@ -204,17 +206,17 @@ fun SessionListScreen(
204206 pendingDelete?.let { session ->
205207 AlertDialog(
206208 onDismissRequest = { pendingDelete = null },
207− title = { Text("Delete session?") },
208− text = { Text("“${session.displayTitle}” will be permanently deleted. This cannot be undone.") },
209+ title = { Text(stringResource(R.string.delete_session_title)) },
210+ text = { Text(stringResource(R.string.delete_session_text, session.displayTitle)) },
209211 confirmButton = {
210212 TextButton(onClick = {
211213 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
212214 pendingDelete = null
213215 vm.deleteSession(session)
214− }) { Text("Delete", color = MaterialTheme.colorScheme.error) }
216+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
215217 },
216218 dismissButton = {
217− TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
219+ TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
218220 },
219221 )
220222 }
@@ -243,21 +245,21 @@ private fun RenameSessionDialog(
243245) {
244246 AlertDialog(
245247 onDismissRequest = onDismiss,
246− title = { Text("Rename session") },
248+ title = { Text(stringResource(R.string.rename_session)) },
247249 text = {
248250 OutlinedTextField(
249251 value = title,
250252 onValueChange = onTitleChange,
251253 modifier = Modifier.fillMaxWidth(),
252− placeholder = { Text("Session title") },
254+ placeholder = { Text(stringResource(R.string.session_title_hint)) },
253255 singleLine = true,
254256 )
255257 },
256258 confirmButton = {
257− TextButton(onClick = onConfirm, enabled = title.isNotBlank()) { Text("Save") }
259+ TextButton(onClick = onConfirm, enabled = title.isNotBlank()) { Text(stringResource(R.string.save)) }
258260 },
259261 dismissButton = {
260− TextButton(onClick = onDismiss) { Text("Cancel") }
262+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
261263 },
262264 )
263265}
@@ -296,10 +298,10 @@ private fun SessionCard(
296298 )
297299 }
298300 IconButton(onClick = onRename) {
299− Icon(Icons.Filled.Edit, contentDescription = "Rename")
301+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
300302 }
301303 IconButton(onClick = onDelete) {
302− Icon(Icons.Filled.Delete, contentDescription = "Delete")
304+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
303305 }
304306 }
305307 if (!preview.isNullOrBlank()) {
@@ -329,17 +331,17 @@ private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
329331 tint = MaterialTheme.colorScheme.onSurfaceVariant,
330332 )
331333 Spacer(Modifier.size(16.dp))
332− Text("No sessions yet", style = MaterialTheme.typography.titleMedium)
334+ Text(stringResource(R.string.no_sessions_yet), style = MaterialTheme.typography.titleMedium)
333335 Spacer(Modifier.size(8.dp))
334336 Text(
335− "Start a conversation with opencode.",
337+ stringResource(R.string.no_sessions_hint),
336338 style = MaterialTheme.typography.bodyMedium,
337339 color = MaterialTheme.colorScheme.onSurfaceVariant,
338340 )
339341 Spacer(Modifier.size(20.dp))
340342 Button(onClick = onCreate) {
341343 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
342− Text(" New session")
344+ Text(" " + stringResource(R.string.new_session))
343345 }
344346 }
345347}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+7−6
@@ -6,6 +6,7 @@ import soy.iko.opencode.data.model.ServerProfile
66import soy.iko.opencode.data.model.Session
77import soy.iko.opencode.data.model.TextPart
88import soy.iko.opencode.di.AppContainer
9+import soy.iko.opencode.R
910import kotlinx.coroutines.Job
1011import kotlinx.coroutines.flow.MutableStateFlow
1112import kotlinx.coroutines.flow.SharingStarted
@@ -71,7 +72,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
7172 fun refresh() {
7273 val conn = container.activeConnection.value
7374 if (conn == null) {
74− _state.value = SessionListState(loading = false, error = "Not connected")
75+ _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
7576 return
7677 }
7778 _serverLabel.value = conn.profile.displayLabel
@@ -83,7 +84,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
8384 _state.value = _state.value.copy(sessions = sorted, loading = false)
8485 loadPreviews(sorted)
8586 }
86− .onFailure { _state.value = SessionListState(loading = false, error = it.message ?: "Failed to load sessions") }
87+ .onFailure { _state.value = SessionListState(loading = false, error = it.message ?: container.string(R.string.error_load_sessions)) }
8788 }
8889 }
8990
@@ -119,7 +120,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
119120 viewModelScope.launch {
120121 runCatching { conn.repository.createSession() }
121122 .onSuccess { onCreated(it.id); refresh() }
122− .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to create session") }
123+ .onFailure { _state.value = _state.value.copy(error = it.message ?: container.string(R.string.error_create_session)) }
123124 }
124125 }
125126
@@ -128,7 +129,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
128129 viewModelScope.launch {
129130 runCatching { conn.repository.deleteSession(session.id) }
130131 .onSuccess { refresh() }
131− .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to delete session") }
132+ .onFailure { _state.value = _state.value.copy(error = it.message ?: container.string(R.string.error_delete_session)) }
132133 }
133134 }
134135
@@ -139,7 +140,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
139140 viewModelScope.launch {
140141 runCatching { conn.api.updateSession(session.id, title) }
141142 .onSuccess { refresh() }
142− .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to rename session") }
143+ .onFailure { _state.value = _state.value.copy(error = it.message ?: container.string(R.string.error_rename_session)) }
143144 }
144145 }
145146
@@ -158,7 +159,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
158159 result.onSuccess { refresh() }
159160 .onFailure {
160161 container.disconnect()
161− _state.value = SessionListState(loading = false, error = it.message ?: "Could not reach ${profile.baseUrl}")
162+ _state.value = SessionListState(loading = false, error = it.message ?: container.string(R.string.error_not_reachable, profile.baseUrl))
162163 refresh()
163164 }
164165 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+20−14
@@ -31,11 +31,13 @@ import androidx.compose.runtime.rememberCoroutineScope
3131import androidx.compose.ui.Alignment
3232import androidx.compose.ui.Modifier
3333import androidx.compose.ui.platform.LocalContext
34+import androidx.compose.ui.res.stringResource
3435import androidx.compose.ui.unit.dp
3536import androidx.lifecycle.compose.collectAsStateWithLifecycle
3637import kotlinx.coroutines.launch
3738import soy.iko.opencode.data.repo.ThemeMode
3839import soy.iko.opencode.di.AppContainer
40+import soy.iko.opencode.R
3941
4042@OptIn(ExperimentalMaterial3Api::class)
4143@Composable
@@ -47,23 +49,23 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
4749 val context = LocalContext.current
4850 val versionName = remember {
4951 runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
50− .getOrDefault("0.1.0")
52+ .getOrNull() ?: "0.1.0"
5153 }
5254
5355 Scaffold(
5456 topBar = {
5557 TopAppBar(
56− title = { Text("Settings") },
58+ title = { Text(stringResource(R.string.settings)) },
5759 navigationIcon = {
5860 IconButton(onClick = onBack) {
59− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
61+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
6062 }
6163 },
6264 )
6365 },
6466 ) { padding ->
6567 Column(modifier = Modifier.padding(padding).padding(16.dp)) {
66− Text("Appearance", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
68+ Text(stringResource(R.string.appearance), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
6769 ThemeMode.entries.forEach { mode ->
6870 ThemeRow(
6971 mode = mode,
@@ -78,9 +80,9 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
7880 verticalAlignment = Alignment.CenterVertically,
7981 ) {
8082 Column(modifier = Modifier.weight(1f)) {
81− Text("Dynamic color", style = MaterialTheme.typography.bodyLarge)
83+ Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
8284 Text(
83− "Use Material You wallpaper colors (Android 12+)",
85+ stringResource(R.string.dynamic_color_desc),
8486 style = MaterialTheme.typography.bodySmall,
8587 color = MaterialTheme.colorScheme.onSurfaceVariant,
8688 )
@@ -93,17 +95,17 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
9395
9496 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
9597
96− Text("Connection", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
98+ Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
9799 if (activeProfile != null) {
98100 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
99101 Text(
100− activeProfile.baseUrl + if (activeProfile.hasAuth) " • Basic auth" else "",
102+ activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
101103 style = MaterialTheme.typography.bodySmall,
102104 color = MaterialTheme.colorScheme.onSurfaceVariant,
103105 )
104106 } else {
105107 Text(
106− "Not connected.",
108+ stringResource(R.string.not_connected),
107109 modifier = Modifier.padding(top = 8.dp),
108110 color = MaterialTheme.colorScheme.onSurfaceVariant,
109111 )
@@ -117,20 +119,20 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
117119 verticalAlignment = Alignment.CenterVertically,
118120 ) {
119121 Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
120− Text(" Manage servers", modifier = Modifier.weight(1f))
122+ Text(" " + stringResource(R.string.manage_servers), modifier = Modifier.weight(1f))
121123 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
122124 }
123125
124126 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
125127
126− Text("About", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
128+ Text(stringResource(R.string.about), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
127129 Text(
128− "opencode-android $versionName",
130+ stringResource(R.string.about_version, versionName),
129131 style = MaterialTheme.typography.bodyMedium,
130132 modifier = Modifier.padding(top = 8.dp),
131133 )
132134 Text(
133− "A native Android client for the opencode serve HTTP API.",
135+ stringResource(R.string.about_desc),
134136 style = MaterialTheme.typography.bodySmall,
135137 color = MaterialTheme.colorScheme.onSurfaceVariant,
136138 modifier = Modifier.padding(top = 4.dp),
@@ -151,7 +153,11 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
151153 ) {
152154 RadioButton(selected = selected, onClick = onSelect)
153155 Text(
154− mode.name.lowercase().replaceFirstChar { it.uppercase() },
156+ when (mode) {
157+ ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
158+ ThemeMode.LIGHT -> stringResource(R.string.theme_light)
159+ ThemeMode.DARK -> stringResource(R.string.theme_dark)
160+ },
155161 style = MaterialTheme.typography.bodyLarge,
156162 )
157163 }
app/src/main/res/values/colors.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <color name="splash_background">#34548A</color>
4+</resources>
app/src/main/res/values/strings.xml+123
@@ -1,4 +1,127 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
33 <string name="app_name">opencode</string>
4+
5+ <!-- Common -->
6+ <string name="back">Back</string>
7+ <string name="cancel">Cancel</string>
8+ <string name="save">Save</string>
9+ <string name="delete">Delete</string>
10+ <string name="edit">Edit</string>
11+ <string name="remove">Remove</string>
12+ <string name="copy">Copy</string>
13+ <string name="copied">Copied</string>
14+ <string name="retry">Retry</string>
15+ <string name="refresh">Refresh</string>
16+ <string name="settings">Settings</string>
17+ <string name="files">Files</string>
18+ <string name="share">Share</string>
19+ <string name="send">Send</string>
20+ <string name="stop">Stop</string>
21+ <string name="rename">Rename</string>
22+ <string name="disconnect">Disconnect</string>
23+
24+ <!-- Theme modes -->
25+ <string name="theme_system">System</string>
26+ <string name="theme_light">Light</string>
27+ <string name="theme_dark">Dark</string>
28+
29+ <!-- Connection banner -->
30+ <string name="connecting">Connecting…</string>
31+ <string name="reconnecting">Reconnecting…</string>
32+ <string name="not_connected">Not connected.</string>
33+
34+ <!-- Server list -->
35+ <string name="servers_title">opencode servers</string>
36+ <string name="add_server">Add server</string>
37+ <string name="edit_server">Edit server</string>
38+ <string name="connected">Connected</string>
39+ <string name="no_servers_yet">No servers yet</string>
40+ <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
41+ <string name="remove_server_title">Remove server?</string>
42+ <string name="remove_server_text">“%1$s” will be removed from your saved servers.</string>
43+ <string name="server_auth_short"> • auth</string>
44+ <string name="server_auth_basic"> • Basic auth</string>
45+
46+ <!-- Server edit -->
47+ <string name="label_optional">Label (optional)</string>
48+ <string name="base_url">Base URL</string>
49+ <string name="base_url_hint">http://192.168.1.10:4096</string>
50+ <string name="auth_help">Auth is optional — only needed if the server runs with OPENCODE_SERVER_PASSWORD. Username defaults to \"opencode\".</string>
51+ <string name="username_optional">Username (optional)</string>
52+ <string name="password_optional">Password (optional)</string>
53+
54+ <!-- Session list -->
55+ <string name="new_session">New session</string>
56+ <string name="no_sessions_yet">No sessions yet</string>
57+ <string name="no_sessions_hint">Start a conversation with opencode.</string>
58+ <string name="search_sessions">Search sessions…</string>
59+ <string name="no_sessions_match">No sessions match “%1$s”.</string>
60+ <string name="switch_server">Switch server</string>
61+ <string name="delete_session_title">Delete session?</string>
62+ <string name="delete_session_text">“%1$s” will be permanently deleted. This cannot be undone.</string>
63+ <string name="rename_session">Rename session</string>
64+ <string name="session_title_hint">Session title</string>
65+
66+ <!-- Chat -->
67+ <string name="session">Session</string>
68+ <string name="default_model">default model</string>
69+ <string name="message_placeholder">Message opencode…</string>
70+ <string name="working">working…</string>
71+ <string name="latest">Latest</string>
72+ <string name="stop_and_exit_title">Stop and exit?</string>
73+ <string name="stop_and_exit_text">The agent is still working. Exit anyway?</string>
74+ <string name="stop_and_exit">Stop &amp; exit</string>
75+ <string name="stay">Stay</string>
76+ <string name="commands">Commands</string>
77+ <string name="choose_agent">Choose agent</string>
78+ <string name="choose_model">Choose model</string>
79+
80+ <!-- Errors (view models) -->
81+ <string name="error_failed_to_send">Failed to send</string>
82+ <string name="error_agent_reported">The agent reported an error.</string>
83+ <string name="error_failed_command">Failed to run command</string>
84+ <string name="error_not_reachable">Could not reach %1$s</string>
85+ <string name="error_load_sessions">Failed to load sessions</string>
86+ <string name="error_create_session">Failed to create session</string>
87+ <string name="error_delete_session">Failed to delete session</string>
88+ <string name="error_rename_session">Failed to rename session</string>
89+
90+ <!-- Part renderer -->
91+ <string name="thinking">Thinking…</string>
92+ <string name="thoughts">Thoughts</string>
93+ <string name="show_more">Show more</string>
94+ <string name="show_less">Show less</string>
95+
96+ <!-- Permission dialog -->
97+ <string name="permission_title">Permission requested</string>
98+ <string name="permission_text">Allow this tool to run?</string>
99+ <string name="allow_once">Allow once</string>
100+ <string name="always_allow">Always allow</string>
101+ <string name="reject">Reject</string>
102+
103+ <!-- Pickers -->
104+ <string name="model">Model</string>
105+ <string name="no_models">No models reported by the server.</string>
106+ <string name="agent">Agent</string>
107+ <string name="no_agents">No agents reported by the server.</string>
108+ <string name="default_agent">Default</string>
109+ <string name="default_agent_desc">Server default agent</string>
110+ <string name="no_commands">No commands reported by the server.</string>
111+
112+ <!-- File browser -->
113+ <string name="search_files">Search files…</string>
114+ <string name="root">Root</string>
115+ <string name="no_matches">No matches.</string>
116+ <string name="binary_file">Binary file — preview not supported.</string>
117+
118+ <!-- Settings -->
119+ <string name="appearance">Appearance</string>
120+ <string name="dynamic_color">Dynamic color</string>
121+ <string name="dynamic_color_desc">Use Material You wallpaper colors (Android 12+)</string>
122+ <string name="connection">Connection</string>
123+ <string name="manage_servers">Manage servers</string>
124+ <string name="about">About</string>
125+ <string name="about_version">opencode-android %1$s</string>
126+ <string name="about_desc">A native Android client for the opencode serve HTTP API.</string>
4127</resources>
app/src/main/res/values/themes.xml+10
@@ -1,4 +1,14 @@
11<?xml version="1.0" encoding="utf-8"?>
2+<!--
3+ The Compose theme (OpencodeTheme) takes over once the UI inflates; this XML
4+ theme only governs the window before Compose and hosts the SplashScreen API.
5+-->
26<resources>
37 <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.NoActionBar" />
8+
9+ <!-- Shown by core-splashscreen on cold start, then swapped for the theme above. -->
10+ <style name="Theme.OpencodeAndroid.Splash" parent="Theme.SplashScreen">
11+ <item name="windowSplashScreenBackground">@color/splash_background</item>
12+ <item name="postSplashScreenTheme">@style/Theme.OpencodeAndroid</item>
13+ </style>
414</resources>
gradle/libs.versions.toml+2
@@ -13,10 +13,12 @@ securityCrypto = "1.1.0-alpha06"
1313coil = "2.7.0"
1414markdownRenderer = "0.27.0"
1515coreKtx = "1.13.1"
16+splashscreen = "1.0.1"
1617junit = "4.13.2"
1718
1819[libraries]
1920androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
21+androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
2022androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
2123androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
2224androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }