git.iko.soy/opencode-android
on master help sign in
Add auto-reconnect, share-into-app, splash screen, and externalize all UI strings
d9842e3iko
app/build.gradle.kts+1
@@ -51,6 +51,7 @@ android {
51
51
52dependencies {
52dependencies {
53 implementation(libs.androidx.core.ktx)
53 implementation(libs.androidx.core.ktx)
54+ implementation(libs.androidx.core.splashscreen)
54 implementation(libs.androidx.activity.compose)
55 implementation(libs.androidx.activity.compose)
55 implementation(libs.androidx.lifecycle.viewmodel.compose)
56 implementation(libs.androidx.lifecycle.viewmodel.compose)
56 implementation(libs.androidx.lifecycle.runtime.compose)
57 implementation(libs.androidx.lifecycle.runtime.compose)
app/src/main/AndroidManifest.xml+9−4
@@ -6,22 +6,27 @@
6
6
7 <application
7 <application
8 android:name=".OpencodeApp"
8 android:name=".OpencodeApp"
9− android:allowBackup="true"
9+ android:allowBackup="false"
10 android:label="@string/app_name"
10 android:label="@string/app_name"
11 android:networkSecurityConfig="@xml/network_security_config"
11 android:networkSecurityConfig="@xml/network_security_config"
12 android:supportsRtl="true"
12 android:supportsRtl="true"
13− android:theme="@style/Theme.OpencodeAndroid"
13+ android:theme="@style/Theme.OpencodeAndroid">
14− android:usesCleartextTraffic="true">
15
14
16 <activity
15 <activity
17 android:name=".MainActivity"
16 android:name=".MainActivity"
18 android:exported="true"
17 android:exported="true"
19 android:label="@string/app_name"
18 android:label="@string/app_name"
19+ android:launchMode="singleTop"
20− android:theme="@style/Theme.OpencodeAndroid">
20+ android:theme="@style/Theme.OpencodeAndroid.Splash">
21 <intent-filter>
21 <intent-filter>
22 <action android:name="android.intent.action.MAIN" />
22 <action android:name="android.intent.action.MAIN" />
23 <category android:name="android.intent.category.LAUNCHER" />
23 <category android:name="android.intent.category.LAUNCHER" />
24 </intent-filter>
24 </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>
25 </activity>
30 </activity>
26 </application>
31 </application>
27
32
app/src/main/java/soy/iko/opencode/MainActivity.kt+17
@@ -1,11 +1,13 @@
1package soy.iko.opencode
1package soy.iko.opencode
2
2
3+import android.content.Intent
3import android.os.Bundle
4import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.activity.compose.setContent
6import androidx.activity.enableEdgeToEdge
7import androidx.activity.enableEdgeToEdge
7import androidx.compose.foundation.isSystemInDarkTheme
8import androidx.compose.foundation.isSystemInDarkTheme
8import androidx.compose.runtime.getValue
9import androidx.compose.runtime.getValue
10+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
9import androidx.lifecycle.compose.collectAsStateWithLifecycle
11import androidx.lifecycle.compose.collectAsStateWithLifecycle
10import soy.iko.opencode.data.repo.ThemeMode
12import soy.iko.opencode.data.repo.ThemeMode
11import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
13import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
@@ -13,9 +15,11 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
13
15
14class MainActivity : ComponentActivity() {
16class MainActivity : ComponentActivity() {
15 override fun onCreate(savedInstanceState: Bundle?) {
17 override fun onCreate(savedInstanceState: Bundle?) {
18+ installSplashScreen()
16 super.onCreate(savedInstanceState)
19 super.onCreate(savedInstanceState)
17 enableEdgeToEdge()
20 enableEdgeToEdge()
18 val container = (application as OpencodeApp).container
21 val container = (application as OpencodeApp).container
22+ if (savedInstanceState == null) handleShareIntent(intent)
19 setContent {
23 setContent {
20 val themeMode by container.settingsStore.themeMode
24 val themeMode by container.settingsStore.themeMode
21 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
25 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
@@ -31,4 +35,17 @@ class MainActivity : ComponentActivity() {
31 }
35 }
32 }
36 }
33 }
37 }
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+ }
34}
51}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+45
@@ -14,6 +14,9 @@ import kotlinx.coroutines.SupervisorJob
14import kotlinx.coroutines.flow.MutableStateFlow
14import kotlinx.coroutines.flow.MutableStateFlow
15import kotlinx.coroutines.flow.StateFlow
15import kotlinx.coroutines.flow.StateFlow
16import kotlinx.coroutines.flow.asStateFlow
16import kotlinx.coroutines.flow.asStateFlow
17+import kotlinx.coroutines.flow.first
18+import kotlinx.coroutines.launch
19+import java.util.concurrent.atomic.AtomicBoolean
17
20
18/**
21/**
19 * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
22 * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
@@ -31,8 +34,50 @@ class AppContainer(context: Context) {
31 private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
34 private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
32 val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
35 val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
33
36
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+
34 init {
61 init {
35 registerNetworkMonitor()
62 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()
36 }
81 }
37
82
38 /**
83 /**
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+17−2
@@ -1,6 +1,7 @@
1package soy.iko.opencode.ui
1package soy.iko.opencode.ui
2
2
3import androidx.compose.runtime.Composable
3import androidx.compose.runtime.Composable
4+import androidx.compose.runtime.LaunchedEffect
4import androidx.compose.runtime.getValue
5import androidx.compose.runtime.getValue
5import androidx.navigation.NavType
6import androidx.navigation.NavType
6import androidx.navigation.compose.NavHost
7import androidx.navigation.compose.NavHost
@@ -20,7 +21,18 @@ import soy.iko.opencode.ui.settings.SettingsScreen
20@Composable
21@Composable
21fun OpencodeApp(container: AppContainer) {
22fun OpencodeApp(container: AppContainer) {
22 val navController = rememberNavController()
23 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+ }
24
36
25 NavHost(navController = navController, startDestination = Routes.SERVERS) {
37 NavHost(navController = navController, startDestination = Routes.SERVERS) {
26
38
@@ -47,7 +59,10 @@ fun OpencodeApp(container: AppContainer) {
47 composable(Routes.SESSIONS) {
59 composable(Routes.SESSIONS) {
48 SessionListScreen(
60 SessionListScreen(
49 container = container,
61 container = container,
62+ onOpenSession = { id ->
63+ container.consumePendingShare()?.let { container.draftStore.set(id, it) }
50− onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
64+ navController.navigate(Routes.chat(id))
65+ },
51 onDisconnect = {
66 onDisconnect = {
52 container.disconnect()
67 container.disconnect()
53 navController.popBackStack(Routes.SERVERS, inclusive = false)
68 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
13import androidx.compose.material3.Text
13import androidx.compose.material3.Text
14import androidx.compose.runtime.Composable
14import androidx.compose.runtime.Composable
15import androidx.compose.ui.Modifier
15import androidx.compose.ui.Modifier
16+import androidx.compose.ui.res.stringResource
16import androidx.compose.ui.unit.dp
17import androidx.compose.ui.unit.dp
17import soy.iko.opencode.data.model.Agent
18import soy.iko.opencode.data.model.Agent
19+import soy.iko.opencode.R
18
20
19/** Bottom sheet that lists every agent and lets the user pick one. */
21/** Bottom sheet that lists every agent and lets the user pick one. */
20@OptIn(ExperimentalMaterial3Api::class)
22@OptIn(ExperimentalMaterial3Api::class)
@@ -27,13 +29,13 @@ fun AgentPickerSheet(
27) {
29) {
28 ModalBottomSheet(onDismissRequest = onDismiss) {
30 ModalBottomSheet(onDismissRequest = onDismiss) {
29 Text(
31 Text(
30− "Agent",
32+ stringResource(R.string.agent),
31 style = MaterialTheme.typography.titleMedium,
33 style = MaterialTheme.typography.titleMedium,
32 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
34 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
33 )
35 )
34 if (agents.isEmpty()) {
36 if (agents.isEmpty()) {
35 Text(
37 Text(
36− "No agents reported by the server.",
38+ stringResource(R.string.no_agents),
37 style = MaterialTheme.typography.bodyMedium,
39 style = MaterialTheme.typography.bodyMedium,
38 color = MaterialTheme.colorScheme.onSurfaceVariant,
40 color = MaterialTheme.colorScheme.onSurfaceVariant,
39 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
41 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
@@ -48,12 +50,12 @@ fun AgentPickerSheet(
48 .padding(horizontal = 24.dp, vertical = 12.dp),
50 .padding(horizontal = 24.dp, vertical = 12.dp),
49 ) {
51 ) {
50 Text(
52 Text(
51− "Default",
53+ stringResource(R.string.default_agent),
52 style = MaterialTheme.typography.bodyLarge,
54 style = MaterialTheme.typography.bodyLarge,
53 color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
55 color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
54 )
56 )
55 Text(
57 Text(
56− "Server default agent" + if (selected == null) " ✓" else "",
58+ stringResource(R.string.default_agent_desc) + if (selected == null) " ✓" else "",
57 style = MaterialTheme.typography.bodySmall,
59 style = MaterialTheme.typography.bodySmall,
58 color = MaterialTheme.colorScheme.onSurfaceVariant,
60 color = MaterialTheme.colorScheme.onSurfaceVariant,
59 )
61 )
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+22−19
@@ -57,12 +57,14 @@ import androidx.compose.ui.input.key.key
57import androidx.compose.ui.input.key.onPreviewKeyEvent
57import androidx.compose.ui.input.key.onPreviewKeyEvent
58import androidx.compose.ui.input.key.type
58import androidx.compose.ui.input.key.type
59import androidx.compose.ui.platform.LocalHapticFeedback
59import androidx.compose.ui.platform.LocalHapticFeedback
60+import androidx.compose.ui.res.stringResource
60import androidx.compose.ui.unit.dp
61import androidx.compose.ui.unit.dp
61import androidx.lifecycle.compose.collectAsStateWithLifecycle
62import androidx.lifecycle.compose.collectAsStateWithLifecycle
62import androidx.lifecycle.viewmodel.compose.viewModel
63import androidx.lifecycle.viewmodel.compose.viewModel
63import kotlinx.coroutines.launch
64import kotlinx.coroutines.launch
64import soy.iko.opencode.data.network.EventStreamClient
65import soy.iko.opencode.data.network.EventStreamClient
65import soy.iko.opencode.di.AppContainer
66import soy.iko.opencode.di.AppContainer
67+import soy.iko.opencode.R
66import soy.iko.opencode.ui.vmFactory
68import soy.iko.opencode.ui.vmFactory
67
69
68@OptIn(ExperimentalMaterial3Api::class)
70@OptIn(ExperimentalMaterial3Api::class)
@@ -111,10 +113,11 @@ fun ChatScreen(
111 listState.animateScrollToItem(messages.lastIndex)
113 listState.animateScrollToItem(messages.lastIndex)
112 }
114 }
113 }
115 }
116+ val retryLabel = stringResource(R.string.retry)
114 LaunchedEffect(error) {
117 LaunchedEffect(error) {
115 val msg = error ?: return@LaunchedEffect
118 val msg = error ?: return@LaunchedEffect
116 val result = if (failedDraft != null) {
119 val result = if (failedDraft != null) {
117− snackbar.showSnackbar(message = msg, actionLabel = "Retry")
120+ snackbar.showSnackbar(message = msg, actionLabel = retryLabel)
118 } else {
121 } else {
119 snackbar.showSnackbar(msg)
122 snackbar.showSnackbar(msg)
120 }
123 }
@@ -135,10 +138,10 @@ fun ChatScreen(
135 TopAppBar(
138 TopAppBar(
136 title = {
139 title = {
137 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
140 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
138− Text(sessionTitle ?: "Session")
141+ Text(sessionTitle ?: stringResource(R.string.session))
139 Text(
142 Text(
140 buildString {
143 buildString {
141− append(selectedModel?.modelLabel ?: "default model")
144+ append(selectedModel?.modelLabel ?: stringResource(R.string.default_model))
142 selectedAgent?.let { append(" • $it") }
145 selectedAgent?.let { append(" • $it") }
143 },
146 },
144 style = MaterialTheme.typography.labelSmall,
147 style = MaterialTheme.typography.labelSmall,
@@ -150,18 +153,18 @@ fun ChatScreen(
150 IconButton(onClick = {
153 IconButton(onClick = {
151 if (running) showExitConfirm = true else onBack()
154 if (running) showExitConfirm = true else onBack()
152 }) {
155 }) {
153− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
156+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
154 }
157 }
155 },
158 },
156 actions = {
159 actions = {
157 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
160 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
158− Icon(Icons.Filled.Terminal, contentDescription = "Commands")
161+ Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
159 }
162 }
160 IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
163 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))
162 }
165 }
163 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
166 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))
165 }
168 }
166 },
169 },
167 )
170 )
@@ -181,7 +184,7 @@ fun ChatScreen(
181 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
184 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
182 if (!vm.connected) {
185 if (!vm.connected) {
183 Text(
186 Text(
184− "Not connected.",
187+ stringResource(R.string.not_connected),
185 modifier = Modifier.align(Alignment.Center).padding(24.dp),
188 modifier = Modifier.align(Alignment.Center).padding(24.dp),
186 color = MaterialTheme.colorScheme.error,
189 color = MaterialTheme.colorScheme.error,
187 )
190 )
@@ -203,7 +206,7 @@ fun ChatScreen(
203 item(key = "__typing") {
206 item(key = "__typing") {
204 Row(verticalAlignment = Alignment.CenterVertically) {
207 Row(verticalAlignment = Alignment.CenterVertically) {
205 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
208 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)
207 }
210 }
208 }
211 }
209 }
212 }
@@ -220,7 +223,7 @@ fun ChatScreen(
220 }
223 }
221 },
224 },
222 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
225 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
223− text = { Text("Latest") },
226+ text = { Text(stringResource(R.string.latest)) },
224 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
227 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
225 )
228 )
226 }
229 }
@@ -231,17 +234,17 @@ fun ChatScreen(
231 if (showExitConfirm) {
234 if (showExitConfirm) {
232 AlertDialog(
235 AlertDialog(
233 onDismissRequest = { showExitConfirm = false },
236 onDismissRequest = { showExitConfirm = false },
234− title = { Text("Stop and exit?") },
237+ title = { Text(stringResource(R.string.stop_and_exit_title)) },
235− text = { Text("The agent is still working. Exit anyway?") },
238+ text = { Text(stringResource(R.string.stop_and_exit_text)) },
236 confirmButton = {
239 confirmButton = {
237 TextButton(onClick = {
240 TextButton(onClick = {
238 showExitConfirm = false
241 showExitConfirm = false
239 vm.abort()
242 vm.abort()
240 onBack()
243 onBack()
241− }) { Text("Stop & exit") }
244+ }) { Text(stringResource(R.string.stop_and_exit)) }
242 },
245 },
243 dismissButton = {
246 dismissButton = {
244− TextButton(onClick = { showExitConfirm = false }) { Text("Stay") }
247+ TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
245 },
248 },
246 )
249 )
247 }
250 }
@@ -286,8 +289,8 @@ private fun ConnectionBanner(
286 modifier: Modifier = Modifier,
289 modifier: Modifier = Modifier,
287) {
290) {
288 val text = when (state) {
291 val text = when (state) {
289− EventStreamClient.ConnectionState.Connecting -> "Connecting…"
292+ EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
290− EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
293+ EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
291 EventStreamClient.ConnectionState.Connected -> null
294 EventStreamClient.ConnectionState.Connected -> null
292 }
295 }
293 if (text != null) {
296 if (text != null) {
@@ -342,13 +345,13 @@ private fun ChatInputBar(
342 false
345 false
343 }
346 }
344 },
347 },
345− placeholder = { Text("Message opencode…") },
348+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
346 enabled = enabled,
349 enabled = enabled,
347 maxLines = 6,
350 maxLines = 6,
348 )
351 )
349 if (running) {
352 if (running) {
350 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
353 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))
352 }
355 }
353 } else {
356 } else {
354 IconButton(
357 IconButton(
@@ -356,7 +359,7 @@ private fun ChatInputBar(
356 enabled = enabled && value.isNotBlank(),
359 enabled = enabled && value.isNotBlank(),
357 modifier = Modifier.padding(start = 4.dp),
360 modifier = Modifier.padding(start = 4.dp),
358 ) {
361 ) {
359− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
362+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
360 }
363 }
361 }
364 }
362 }
365 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+4−3
@@ -16,6 +16,7 @@ import soy.iko.opencode.data.model.toOptions
16import soy.iko.opencode.data.network.EventStreamClient
16import soy.iko.opencode.data.network.EventStreamClient
17import soy.iko.opencode.data.repo.SessionRepository
17import soy.iko.opencode.data.repo.SessionRepository
18import soy.iko.opencode.di.AppContainer
18import soy.iko.opencode.di.AppContainer
19+import soy.iko.opencode.R
19import kotlinx.coroutines.flow.MutableStateFlow
20import kotlinx.coroutines.flow.MutableStateFlow
20import kotlinx.coroutines.flow.SharingStarted
21import kotlinx.coroutines.flow.SharingStarted
21import kotlinx.coroutines.flow.StateFlow
22import kotlinx.coroutines.flow.StateFlow
@@ -100,7 +101,7 @@ class ChatViewModel(
100 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
101 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
101 if (SessionRepository.isError(event, sessionId)) {
102 if (SessionRepository.isError(event, sessionId)) {
102 _running.value = false
103 _running.value = false
103− _error.value = "The agent reported an error."
104+ _error.value = container.string(R.string.error_agent_reported)
104 }
105 }
105 when (event) {
106 when (event) {
106 is PermissionUpdated ->
107 is PermissionUpdated ->
@@ -160,7 +161,7 @@ class ChatViewModel(
160 if (!ok) {
161 if (!ok) {
161 _failedDraft.value = trimmed
162 _failedDraft.value = trimmed
162 updateDraft(trimmed)
163 updateDraft(trimmed)
163− _error.value = "Failed to send"
164+ _error.value = container.string(R.string.error_failed_to_send)
164 }
165 }
165 }
166 }
166 return true
167 return true
@@ -182,7 +183,7 @@ class ChatViewModel(
182 viewModelScope.launch {
183 viewModelScope.launch {
183 runCatching {
184 runCatching {
184 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
185 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) }
186 _running.value = false
187 _running.value = false
187 }
188 }
188 }
189 }
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+4−2
@@ -16,9 +16,11 @@ import androidx.compose.material3.ModalBottomSheet
16import androidx.compose.material3.Text
16import androidx.compose.material3.Text
17import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
18import androidx.compose.ui.Modifier
18import androidx.compose.ui.Modifier
19+import androidx.compose.ui.res.stringResource
19import androidx.compose.ui.text.style.TextOverflow
20import androidx.compose.ui.text.style.TextOverflow
20import androidx.compose.ui.unit.dp
21import androidx.compose.ui.unit.dp
21import soy.iko.opencode.data.model.Command
22import soy.iko.opencode.data.model.Command
23+import soy.iko.opencode.R
22
24
23/** Bottom sheet that lists slash-commands; selecting one invokes it. */
25/** Bottom sheet that lists slash-commands; selecting one invokes it. */
24@OptIn(ExperimentalMaterial3Api::class)
26@OptIn(ExperimentalMaterial3Api::class)
@@ -30,13 +32,13 @@ fun CommandPickerSheet(
30) {
32) {
31 ModalBottomSheet(onDismissRequest = onDismiss) {
33 ModalBottomSheet(onDismissRequest = onDismiss) {
32 Text(
34 Text(
33− "Commands",
35+ stringResource(R.string.commands),
34 style = MaterialTheme.typography.titleMedium,
36 style = MaterialTheme.typography.titleMedium,
35 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
37 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
36 )
38 )
37 if (commands.isEmpty()) {
39 if (commands.isEmpty()) {
38 Text(
40 Text(
39− "No commands reported by the server.",
41+ stringResource(R.string.no_commands),
40 style = MaterialTheme.typography.bodyMedium,
42 style = MaterialTheme.typography.bodyMedium,
41 color = MaterialTheme.colorScheme.onSurfaceVariant,
43 color = MaterialTheme.colorScheme.onSurfaceVariant,
42 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
44 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
13import androidx.compose.material3.Text
13import androidx.compose.material3.Text
14import androidx.compose.runtime.Composable
14import androidx.compose.runtime.Composable
15import androidx.compose.ui.Modifier
15import androidx.compose.ui.Modifier
16+import androidx.compose.ui.res.stringResource
16import androidx.compose.ui.unit.dp
17import androidx.compose.ui.unit.dp
17import soy.iko.opencode.data.model.ModelOption
18import soy.iko.opencode.data.model.ModelOption
19+import soy.iko.opencode.R
18
20
19/** Bottom sheet that lists every provider/model and lets the user pick one. */
21/** Bottom sheet that lists every provider/model and lets the user pick one. */
20@OptIn(ExperimentalMaterial3Api::class)
22@OptIn(ExperimentalMaterial3Api::class)
@@ -27,13 +29,13 @@ fun ModelPickerSheet(
27) {
29) {
28 ModalBottomSheet(onDismissRequest = onDismiss) {
30 ModalBottomSheet(onDismissRequest = onDismiss) {
29 Text(
31 Text(
30− "Model",
32+ stringResource(R.string.model),
31 style = MaterialTheme.typography.titleMedium,
33 style = MaterialTheme.typography.titleMedium,
32 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
34 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
33 )
35 )
34 if (options.isEmpty()) {
36 if (options.isEmpty()) {
35 Text(
37 Text(
36− "No models reported by the server.",
38+ stringResource(R.string.no_models),
37 style = MaterialTheme.typography.bodyMedium,
39 style = MaterialTheme.typography.bodyMedium,
38 color = MaterialTheme.colorScheme.onSurfaceVariant,
40 color = MaterialTheme.colorScheme.onSurfaceVariant,
39 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
41 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
33import androidx.compose.ui.draw.clip
33import androidx.compose.ui.draw.clip
34import androidx.compose.ui.platform.LocalContext
34import androidx.compose.ui.platform.LocalContext
35import androidx.compose.ui.text.font.FontFamily
35import androidx.compose.ui.text.font.FontFamily
36+import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.unit.dp
37import androidx.compose.ui.unit.dp
37import soy.iko.opencode.data.model.FilePart
38import soy.iko.opencode.data.model.FilePart
38import soy.iko.opencode.data.model.Part
39import soy.iko.opencode.data.model.Part
@@ -52,6 +53,7 @@ import soy.iko.opencode.ui.components.DiffView
52import soy.iko.opencode.ui.components.MarkdownText
53import soy.iko.opencode.ui.components.MarkdownText
53import soy.iko.opencode.ui.components.copyToClipboard
54import soy.iko.opencode.ui.components.copyToClipboard
54import soy.iko.opencode.ui.components.looksLikeDiff
55import soy.iko.opencode.ui.components.looksLikeDiff
56+import soy.iko.opencode.R
55
57
56private const val COLLAPSED_LIMIT = 600
58private const val COLLAPSED_LIMIT = 600
57
59
@@ -99,7 +101,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
99 )
101 )
100 }
102 }
101 Text(
103 Text(
102− " ${if (streaming) "Thinking…" else "Thoughts"}",
104+ " ${if (streaming) stringResource(R.string.thinking) else stringResource(R.string.thoughts)}",
103 style = MaterialTheme.typography.labelMedium,
105 style = MaterialTheme.typography.labelMedium,
104 color = MaterialTheme.colorScheme.onSurfaceVariant,
106 color = MaterialTheme.colorScheme.onSurfaceVariant,
105 )
107 )
@@ -116,7 +118,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
116 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
118 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
117 ) {
119 ) {
118 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
120 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)
120 }
122 }
121 }
123 }
122 }
124 }
@@ -171,7 +173,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
171 modifier = Modifier.size(16.dp),
173 modifier = Modifier.size(16.dp),
172 )
174 )
173 Text(
175 Text(
174− " ${if (expanded) "Show less" else "Show more"}",
176+ " ${if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more)}",
175 style = MaterialTheme.typography.labelSmall,
177 style = MaterialTheme.typography.labelSmall,
176 )
178 )
177 }
179 }
@@ -181,7 +183,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
181 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
183 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
182 ) {
184 ) {
183 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
185 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)
185 }
187 }
186 }
188 }
187 }
189 }
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+7−5
@@ -17,10 +17,12 @@ import androidx.compose.material3.TextButton
17import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
18import androidx.compose.ui.Modifier
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.platform.LocalHapticFeedback
19import androidx.compose.ui.platform.LocalHapticFeedback
20+import androidx.compose.ui.res.stringResource
20import androidx.compose.ui.text.font.FontFamily
21import androidx.compose.ui.text.font.FontFamily
21import androidx.compose.ui.unit.dp
22import androidx.compose.ui.unit.dp
22import soy.iko.opencode.data.model.Permission
23import soy.iko.opencode.data.model.Permission
23import soy.iko.opencode.data.model.PermissionResponse
24import soy.iko.opencode.data.model.PermissionResponse
25+import soy.iko.opencode.R
24
26
25/**
27/**
26 * Modal asking the user to approve a tool the agent wants to run. The agent run is
28 * Modal asking the user to approve a tool the agent wants to run. The agent run is
@@ -36,7 +38,7 @@ fun PermissionDialog(
36 AlertDialog(
38 AlertDialog(
37 onDismissRequest = { /* require an explicit choice */ },
39 onDismissRequest = { /* require an explicit choice */ },
38 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
40 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
39− title = { Text(permission.title ?: "Permission requested") },
41+ title = { Text(permission.title ?: stringResource(R.string.permission_title)) },
40 text = {
42 text = {
41 Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
43 Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
42 permission.type?.let {
44 permission.type?.let {
@@ -51,7 +53,7 @@ fun PermissionDialog(
51 )
53 )
52 }
54 }
53 Text(
55 Text(
54− "Allow this tool to run?",
56+ stringResource(R.string.permission_text),
55 style = MaterialTheme.typography.bodySmall,
57 style = MaterialTheme.typography.bodySmall,
56 color = MaterialTheme.colorScheme.onSurfaceVariant,
58 color = MaterialTheme.colorScheme.onSurfaceVariant,
57 )
59 )
@@ -68,14 +70,14 @@ fun PermissionDialog(
68 onRespond(PermissionResponse.ONCE)
70 onRespond(PermissionResponse.ONCE)
69 },
71 },
70 modifier = Modifier.fillMaxWidth(),
72 modifier = Modifier.fillMaxWidth(),
71− ) { Text("Allow once") }
73+ ) { Text(stringResource(R.string.allow_once)) }
72 OutlinedButton(
74 OutlinedButton(
73 onClick = {
75 onClick = {
74 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
76 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
75 onRespond(PermissionResponse.ALWAYS)
77 onRespond(PermissionResponse.ALWAYS)
76 },
78 },
77 modifier = Modifier.fillMaxWidth(),
79 modifier = Modifier.fillMaxWidth(),
78− ) { Text("Always allow") }
80+ ) { Text(stringResource(R.string.always_allow)) }
79 }
81 }
80 },
82 },
81 dismissButton = {
83 dismissButton = {
@@ -83,7 +85,7 @@ fun PermissionDialog(
83 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
85 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
84 onRespond(PermissionResponse.REJECT)
86 onRespond(PermissionResponse.REJECT)
85 }) {
87 }) {
86− Text("Reject", color = MaterialTheme.colorScheme.error)
88+ Text(stringResource(R.string.reject), color = MaterialTheme.colorScheme.error)
87 }
89 }
88 },
90 },
89 )
91 )
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+3−1
@@ -26,6 +26,8 @@ import androidx.compose.ui.graphics.Color
26import androidx.compose.ui.platform.LocalContext
26import androidx.compose.ui.platform.LocalContext
27import androidx.compose.ui.text.font.FontFamily
27import androidx.compose.ui.text.font.FontFamily
28import androidx.compose.ui.unit.dp
28import androidx.compose.ui.unit.dp
29+import androidx.compose.ui.res.stringResource
30+import soy.iko.opencode.R
29
31
30/** A single line of a parsed unified diff. */
32/** A single line of a parsed unified diff. */
31sealed interface DiffLine {
33sealed interface DiffLine {
@@ -91,7 +93,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
91 ) {
93 ) {
92 Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
94 Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
93 IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
95 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)
95 }
97 }
96 }
98 }
97 Column(
99 Column(
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+2−1
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
12import androidx.compose.ui.platform.LocalContext
12import androidx.compose.ui.platform.LocalContext
13import androidx.compose.ui.text.TextStyle
13import androidx.compose.ui.text.TextStyle
14import com.mikepenz.markdown.m3.Markdown
14import com.mikepenz.markdown.m3.Markdown
15+import soy.iko.opencode.R
15
16
16/**
17/**
17 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
18 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
@@ -39,5 +40,5 @@ fun MarkdownText(
39internal fun copyToClipboard(context: Context, label: String, text: String = label) {
40internal fun copyToClipboard(context: Context, label: String, text: String = label) {
40 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
41 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
41 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
42 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()
43}
44}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+7−5
@@ -37,10 +37,12 @@ import androidx.compose.ui.graphics.Color
37import androidx.compose.ui.text.font.FontFamily
37import androidx.compose.ui.text.font.FontFamily
38import androidx.compose.ui.text.style.TextOverflow
38import androidx.compose.ui.text.style.TextOverflow
39import androidx.compose.ui.unit.dp
39import androidx.compose.ui.unit.dp
40+import androidx.compose.ui.res.stringResource
40import androidx.lifecycle.compose.collectAsStateWithLifecycle
41import androidx.lifecycle.compose.collectAsStateWithLifecycle
41import androidx.lifecycle.viewmodel.compose.viewModel
42import androidx.lifecycle.viewmodel.compose.viewModel
42import soy.iko.opencode.data.model.FileStatusEntry
43import soy.iko.opencode.data.model.FileStatusEntry
43import soy.iko.opencode.di.AppContainer
44import soy.iko.opencode.di.AppContainer
45+import soy.iko.opencode.R
44import soy.iko.opencode.ui.vmFactory
46import soy.iko.opencode.ui.vmFactory
45
47
46@OptIn(ExperimentalMaterial3Api::class)
48@OptIn(ExperimentalMaterial3Api::class)
@@ -56,10 +58,10 @@ fun FileBrowserScreen(
56 Scaffold(
58 Scaffold(
57 topBar = {
59 topBar = {
58 TopAppBar(
60 TopAppBar(
59− title = { Text("Files", maxLines = 1, overflow = TextOverflow.Ellipsis) },
61+ title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
60 navigationIcon = {
62 navigationIcon = {
61 IconButton(onClick = onBack) {
63 IconButton(onClick = onBack) {
62− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
64+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
63 }
65 }
64 },
66 },
65 )
67 )
@@ -76,7 +78,7 @@ fun FileBrowserScreen(
76 value = state.query,
78 value = state.query,
77 onValueChange = vm::setQuery,
79 onValueChange = vm::setQuery,
78 modifier = Modifier.fillMaxWidth().padding(12.dp),
80 modifier = Modifier.fillMaxWidth().padding(12.dp),
79− placeholder = { Text("Search files…") },
81+ placeholder = { Text(stringResource(R.string.search_files)) },
80 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
82 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
81 singleLine = true,
83 singleLine = true,
82 )
84 )
@@ -112,7 +114,7 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
112 verticalAlignment = Alignment.CenterVertically,
114 verticalAlignment = Alignment.CenterVertically,
113 ) {
115 ) {
114 IconButton(onClick = { onNavigate("") }, modifier = Modifier.size(20.dp)) {
116 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))
116 }
118 }
117 var acc = ""
119 var acc = ""
118 segments.forEachIndexed { index, segment ->
120 segments.forEachIndexed { index, segment ->
@@ -137,7 +139,7 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
137@Composable
139@Composable
138private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
140private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
139 if (results.isEmpty()) {
141 if (results.isEmpty()) {
140− Text("No matches.", modifier = Modifier.padding(24.dp))
142+ Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
141 return
143 return
142 }
144 }
143 LazyColumn(modifier = Modifier.fillMaxSize()) {
145 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
33import androidx.compose.ui.text.font.FontFamily
33import androidx.compose.ui.text.font.FontFamily
34import androidx.compose.ui.text.style.TextOverflow
34import androidx.compose.ui.text.style.TextOverflow
35import androidx.compose.ui.unit.dp
35import androidx.compose.ui.unit.dp
36+import androidx.compose.ui.res.stringResource
36import androidx.lifecycle.compose.collectAsStateWithLifecycle
37import androidx.lifecycle.compose.collectAsStateWithLifecycle
37import androidx.lifecycle.viewmodel.compose.viewModel
38import androidx.lifecycle.viewmodel.compose.viewModel
38import soy.iko.opencode.di.AppContainer
39import soy.iko.opencode.di.AppContainer
40+import soy.iko.opencode.R
39import soy.iko.opencode.ui.components.DiffView
41import soy.iko.opencode.ui.components.DiffView
40import soy.iko.opencode.ui.components.copyToClipboard
42import soy.iko.opencode.ui.components.copyToClipboard
41import soy.iko.opencode.ui.vmFactory
43import soy.iko.opencode.ui.vmFactory
@@ -51,6 +53,7 @@ fun FileViewScreen(
51 val state by vm.state.collectAsStateWithLifecycle()
53 val state by vm.state.collectAsStateWithLifecycle()
52 val context = LocalContext.current
54 val context = LocalContext.current
53 val filename = remember(path) { path.substringAfterLast('/') }
55 val filename = remember(path) { path.substringAfterLast('/') }
56+ val shareLabel = stringResource(R.string.share)
54
57
55 Scaffold(
58 Scaffold(
56 topBar = {
59 topBar = {
@@ -58,14 +61,14 @@ fun FileViewScreen(
58 title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
61 title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
59 navigationIcon = {
62 navigationIcon = {
60 IconButton(onClick = onBack) {
63 IconButton(onClick = onBack) {
61− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
64+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
62 }
65 }
63 },
66 },
64 actions = {
67 actions = {
65 val content = state.content?.content.orEmpty()
68 val content = state.content?.content.orEmpty()
66 if (content.isNotEmpty()) {
69 if (content.isNotEmpty()) {
67 IconButton(onClick = { copyToClipboard(context, filename, content) }) {
70 IconButton(onClick = { copyToClipboard(context, filename, content) }) {
68− Icon(Icons.Filled.ContentCopy, contentDescription = "Copy")
71+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
69 }
72 }
70 IconButton(onClick = {
73 IconButton(onClick = {
71 val send = Intent(Intent.ACTION_SEND).apply {
74 val send = Intent(Intent.ACTION_SEND).apply {
@@ -73,9 +76,9 @@ fun FileViewScreen(
73 putExtra(Intent.EXTRA_SUBJECT, filename)
76 putExtra(Intent.EXTRA_SUBJECT, filename)
74 putExtra(Intent.EXTRA_TEXT, content)
77 putExtra(Intent.EXTRA_TEXT, content)
75 }
78 }
76− context.startActivity(Intent.createChooser(send, "Share $filename"))
79+ context.startActivity(Intent.createChooser(send, shareLabel))
77 }) {
80 }) {
78− Icon(Icons.Filled.Share, contentDescription = "Share")
81+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
79 }
82 }
80 }
83 }
81 },
84 },
@@ -91,7 +94,7 @@ fun FileViewScreen(
91 color = MaterialTheme.colorScheme.error,
94 color = MaterialTheme.colorScheme.error,
92 )
95 )
93 state.content?.isBinary == true -> Text(
96 state.content?.isBinary == true -> Text(
94− "Binary file — preview not supported.",
97+ stringResource(R.string.binary_file),
95 modifier = Modifier.align(Alignment.Center).padding(24.dp),
98 modifier = Modifier.align(Alignment.Center).padding(24.dp),
96 color = MaterialTheme.colorScheme.onSurfaceVariant,
99 color = MaterialTheme.colorScheme.onSurfaceVariant,
97 )
100 )
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+11−10
@@ -22,10 +22,12 @@ import androidx.compose.ui.text.input.KeyboardType
22import androidx.compose.ui.text.input.PasswordVisualTransformation
22import androidx.compose.ui.text.input.PasswordVisualTransformation
23import androidx.compose.foundation.text.KeyboardOptions
23import androidx.compose.foundation.text.KeyboardOptions
24import androidx.compose.foundation.layout.Arrangement
24import androidx.compose.foundation.layout.Arrangement
25+import androidx.compose.ui.res.stringResource
25import androidx.compose.ui.unit.dp
26import androidx.compose.ui.unit.dp
26import androidx.lifecycle.compose.collectAsStateWithLifecycle
27import androidx.lifecycle.compose.collectAsStateWithLifecycle
27import androidx.lifecycle.viewmodel.compose.viewModel
28import androidx.lifecycle.viewmodel.compose.viewModel
28import soy.iko.opencode.di.AppContainer
29import soy.iko.opencode.di.AppContainer
30+import soy.iko.opencode.R
29import soy.iko.opencode.ui.vmFactory
31import soy.iko.opencode.ui.vmFactory
30
32
31@OptIn(ExperimentalMaterial3Api::class)
33@OptIn(ExperimentalMaterial3Api::class)
@@ -42,10 +44,10 @@ fun ServerEditScreen(
42 Scaffold(
44 Scaffold(
43 topBar = {
45 topBar = {
44 TopAppBar(
46 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)) },
46 navigationIcon = {
48 navigationIcon = {
47 IconButton(onClick = onDone) {
49 IconButton(onClick = onDone) {
48− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
50+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
49 }
51 }
50 },
52 },
51 )
53 )
@@ -61,35 +63,34 @@ fun ServerEditScreen(
61 OutlinedTextField(
63 OutlinedTextField(
62 value = state.label,
64 value = state.label,
63 onValueChange = { v -> vm.update { it.copy(label = v) } },
65 onValueChange = { v -> vm.update { it.copy(label = v) } },
64− label = { Text("Label (optional)") },
66+ label = { Text(stringResource(R.string.label_optional)) },
65 singleLine = true,
67 singleLine = true,
66 modifier = Modifier.fillMaxWidth(),
68 modifier = Modifier.fillMaxWidth(),
67 )
69 )
68 OutlinedTextField(
70 OutlinedTextField(
69 value = state.baseUrl,
71 value = state.baseUrl,
70 onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
72 onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
71− label = { Text("Base URL") },
73+ label = { Text(stringResource(R.string.base_url)) },
72− placeholder = { Text("http://192.168.1.10:4096") },
74+ placeholder = { Text(stringResource(R.string.base_url_hint)) },
73 singleLine = true,
75 singleLine = true,
74 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
76 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
75 modifier = Modifier.fillMaxWidth(),
77 modifier = Modifier.fillMaxWidth(),
76 )
78 )
77 Text(
79 Text(
78− "Auth is optional — only needed if the server runs with OPENCODE_SERVER_PASSWORD. " +
80+ stringResource(R.string.auth_help),
79− "Username defaults to \"opencode\".",
80 style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
81 style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
81 )
82 )
82 OutlinedTextField(
83 OutlinedTextField(
83 value = state.username,
84 value = state.username,
84 onValueChange = { v -> vm.update { it.copy(username = v) } },
85 onValueChange = { v -> vm.update { it.copy(username = v) } },
85− label = { Text("Username (optional)") },
86+ label = { Text(stringResource(R.string.username_optional)) },
86 singleLine = true,
87 singleLine = true,
87 modifier = Modifier.fillMaxWidth(),
88 modifier = Modifier.fillMaxWidth(),
88 )
89 )
89 OutlinedTextField(
90 OutlinedTextField(
90 value = state.password,
91 value = state.password,
91 onValueChange = { v -> vm.update { it.copy(password = v) } },
92 onValueChange = { v -> vm.update { it.copy(password = v) } },
92− label = { Text("Password (optional)") },
93+ label = { Text(stringResource(R.string.password_optional)) },
93 singleLine = true,
94 singleLine = true,
94 visualTransformation = PasswordVisualTransformation(),
95 visualTransformation = PasswordVisualTransformation(),
95 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
96 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
@@ -100,7 +101,7 @@ fun ServerEditScreen(
100 enabled = state.canSave,
101 enabled = state.canSave,
101 modifier = Modifier.fillMaxWidth(),
102 modifier = Modifier.fillMaxWidth(),
102 ) {
103 ) {
103− Text("Save")
104+ Text(stringResource(R.string.save))
104 }
105 }
105 }
106 }
106 }
107 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+38−20
@@ -25,6 +25,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
25import androidx.compose.material3.FloatingActionButton
25import androidx.compose.material3.FloatingActionButton
26import androidx.compose.material3.Icon
26import androidx.compose.material3.Icon
27import androidx.compose.material3.IconButton
27import androidx.compose.material3.IconButton
28+import androidx.compose.material3.LinearProgressIndicator
28import androidx.compose.material3.MaterialTheme
29import androidx.compose.material3.MaterialTheme
29import androidx.compose.material3.Scaffold
30import androidx.compose.material3.Scaffold
30import androidx.compose.material3.SnackbarHost
31import androidx.compose.material3.SnackbarHost
@@ -43,10 +44,12 @@ import androidx.compose.ui.Modifier
43import androidx.compose.ui.platform.LocalHapticFeedback
44import androidx.compose.ui.platform.LocalHapticFeedback
44import androidx.compose.ui.text.style.TextOverflow
45import androidx.compose.ui.text.style.TextOverflow
45import androidx.compose.ui.unit.dp
46import androidx.compose.ui.unit.dp
47+import androidx.compose.ui.res.stringResource
46import androidx.lifecycle.compose.collectAsStateWithLifecycle
48import androidx.lifecycle.compose.collectAsStateWithLifecycle
47import androidx.lifecycle.viewmodel.compose.viewModel
49import androidx.lifecycle.viewmodel.compose.viewModel
48import soy.iko.opencode.data.model.ServerProfile
50import soy.iko.opencode.data.model.ServerProfile
49import soy.iko.opencode.di.AppContainer
51import soy.iko.opencode.di.AppContainer
52+import soy.iko.opencode.R
50import soy.iko.opencode.ui.vmFactory
53import soy.iko.opencode.ui.vmFactory
51
54
52@OptIn(ExperimentalMaterial3Api::class)
55@OptIn(ExperimentalMaterial3Api::class)
@@ -62,6 +65,7 @@ fun ServerListScreen(
62 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
65 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
63 val error by vm.error.collectAsStateWithLifecycle()
66 val error by vm.error.collectAsStateWithLifecycle()
64 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
67 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
68+ val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
65 val haptics = LocalHapticFeedback.current
69 val haptics = LocalHapticFeedback.current
66 val snackbar = remember { SnackbarHostState() }
70 val snackbar = remember { SnackbarHostState() }
67 var pendingDelete by remember { mutableStateOf<ServerProfile?>(null) }
71 var pendingDelete by remember { mutableStateOf<ServerProfile?>(null) }
@@ -74,21 +78,34 @@ fun ServerListScreen(
74 }
78 }
75 }
79 }
76
80
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+
77 Scaffold(
90 Scaffold(
78− topBar = { TopAppBar(title = { Text("opencode servers") }) },
91+ topBar = { TopAppBar(title = { Text(stringResource(R.string.servers_title)) }) },
79 snackbarHost = { SnackbarHost(snackbar) },
92 snackbarHost = { SnackbarHost(snackbar) },
80 floatingActionButton = {
93 floatingActionButton = {
81 FloatingActionButton(onClick = onAddProfile) {
94 FloatingActionButton(onClick = onAddProfile) {
82− Icon(Icons.Filled.Add, contentDescription = "Add server")
95+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_server))
83 }
96 }
84 },
97 },
85 ) { padding ->
98 ) { padding ->
99+ Column(modifier = Modifier.fillMaxSize()) {
100+ if (reconnecting && profiles.isNotEmpty()) {
101+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
102+ }
86− if (profiles.isEmpty()) {
103+ if (profiles.isEmpty()) {
87− EmptyServers(
104+ EmptyServers(
88− onAdd = onAddProfile,
105+ onAdd = onAddProfile,
89− modifier = Modifier.fillMaxSize().padding(padding),
106+ modifier = Modifier.fillMaxSize().padding(padding),
90− )
107+ )
91− } else {
108+ } else {
92 LazyColumn(
109 LazyColumn(
93 modifier = Modifier.fillMaxSize().padding(padding),
110 modifier = Modifier.fillMaxSize().padding(padding),
94 contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
111 contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
@@ -115,7 +132,7 @@ fun ServerListScreen(
115 overflow = TextOverflow.Ellipsis,
132 overflow = TextOverflow.Ellipsis,
116 )
133 )
117 Text(
134 Text(
118− profile.baseUrl + if (profile.hasAuth) " • auth" else "",
135+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
119 style = MaterialTheme.typography.bodySmall,
136 style = MaterialTheme.typography.bodySmall,
120 color = MaterialTheme.colorScheme.onSurfaceVariant,
137 color = MaterialTheme.colorScheme.onSurfaceVariant,
121 maxLines = 1,
138 maxLines = 1,
@@ -123,7 +140,7 @@ fun ServerListScreen(
123 )
140 )
124 if (isActive) {
141 if (isActive) {
125 Text(
142 Text(
126− "Connected",
143+ stringResource(R.string.connected),
127 style = MaterialTheme.typography.labelSmall,
144 style = MaterialTheme.typography.labelSmall,
128 color = MaterialTheme.colorScheme.primary,
145 color = MaterialTheme.colorScheme.primary,
129 )
146 )
@@ -132,7 +149,7 @@ fun ServerListScreen(
132 if (isActive) {
149 if (isActive) {
133 Icon(
150 Icon(
134 Icons.Filled.CheckCircle,
151 Icons.Filled.CheckCircle,
135− contentDescription = "Connected",
152+ contentDescription = stringResource(R.string.connected),
136 tint = MaterialTheme.colorScheme.primary,
153 tint = MaterialTheme.colorScheme.primary,
137 modifier = Modifier.size(22.dp),
154 modifier = Modifier.size(22.dp),
138 )
155 )
@@ -140,33 +157,34 @@ fun ServerListScreen(
140 CircularProgressIndicator(modifier = Modifier.size(24.dp))
157 CircularProgressIndicator(modifier = Modifier.size(24.dp))
141 } else {
158 } else {
142 IconButton(onClick = { onEditProfile(profile.id) }) {
159 IconButton(onClick = { onEditProfile(profile.id) }) {
143− Icon(Icons.Filled.Edit, contentDescription = "Edit")
160+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
144 }
161 }
145 IconButton(onClick = { pendingDelete = profile }) {
162 IconButton(onClick = { pendingDelete = profile }) {
146− Icon(Icons.Filled.Delete, contentDescription = "Delete")
163+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
147 }
164 }
148 }
165 }
149 }
166 }
150 }
167 }
151 }
168 }
152 }
169 }
170+ }
153 }
171 }
154 }
172 }
155
173
156 pendingDelete?.let { profile ->
174 pendingDelete?.let { profile ->
157 AlertDialog(
175 AlertDialog(
158 onDismissRequest = { pendingDelete = null },
176 onDismissRequest = { pendingDelete = null },
159− title = { Text("Remove server?") },
177+ title = { Text(stringResource(R.string.remove_server_title)) },
160− text = { Text("“${profile.displayLabel}” will be removed from your saved servers.") },
178+ text = { Text(stringResource(R.string.remove_server_text, profile.displayLabel)) },
161 confirmButton = {
179 confirmButton = {
162 TextButton(onClick = {
180 TextButton(onClick = {
163 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
181 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
164 pendingDelete = null
182 pendingDelete = null
165 vm.delete(profile)
183 vm.delete(profile)
166− }) { Text("Remove", color = MaterialTheme.colorScheme.error) }
184+ }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
167 },
185 },
168 dismissButton = {
186 dismissButton = {
169− TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
187+ TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
170 },
188 },
171 )
189 )
172 }
190 }
@@ -186,17 +204,17 @@ private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
186 tint = MaterialTheme.colorScheme.onSurfaceVariant,
204 tint = MaterialTheme.colorScheme.onSurfaceVariant,
187 )
205 )
188 Spacer(Modifier.size(16.dp))
206 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)
190 Spacer(Modifier.size(8.dp))
208 Spacer(Modifier.size(8.dp))
191 Text(
209 Text(
192− "Add an opencode serve endpoint to connect.",
210+ stringResource(R.string.no_servers_hint),
193 style = MaterialTheme.typography.bodyMedium,
211 style = MaterialTheme.typography.bodyMedium,
194 color = MaterialTheme.colorScheme.onSurfaceVariant,
212 color = MaterialTheme.colorScheme.onSurfaceVariant,
195 )
213 )
196 Spacer(Modifier.size(20.dp))
214 Spacer(Modifier.size(20.dp))
197 TextButton(onClick = onAdd) {
215 TextButton(onClick = onAdd) {
198 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
216 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
199− Text(" Add server")
217+ Text(" " + stringResource(R.string.add_server))
200 }
218 }
201 }
219 }
202 }
220 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+2−1
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.ServerProfile
5import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.di.AppContainer
6import soy.iko.opencode.di.AppContainer
7+import soy.iko.opencode.R
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.SharingStarted
9import kotlinx.coroutines.flow.SharingStarted
9import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.StateFlow
@@ -41,7 +42,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
41 result.onSuccess { onConnected() }
42 result.onSuccess { onConnected() }
42 .onFailure {
43 .onFailure {
43 container.disconnect()
44 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)
45 }
46 }
46 }
47 }
47 }
48 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+23−21
@@ -50,10 +50,12 @@ import androidx.compose.ui.Modifier
50import androidx.compose.ui.platform.LocalHapticFeedback
50import androidx.compose.ui.platform.LocalHapticFeedback
51import androidx.compose.ui.text.style.TextOverflow
51import androidx.compose.ui.text.style.TextOverflow
52import androidx.compose.ui.unit.dp
52import androidx.compose.ui.unit.dp
53+import androidx.compose.ui.res.stringResource
53import androidx.lifecycle.compose.collectAsStateWithLifecycle
54import androidx.lifecycle.compose.collectAsStateWithLifecycle
54import androidx.lifecycle.viewmodel.compose.viewModel
55import androidx.lifecycle.viewmodel.compose.viewModel
55import soy.iko.opencode.data.model.Session
56import soy.iko.opencode.data.model.Session
56import soy.iko.opencode.di.AppContainer
57import soy.iko.opencode.di.AppContainer
58+import soy.iko.opencode.R
57import soy.iko.opencode.ui.components.relativeTime
59import soy.iko.opencode.ui.components.relativeTime
58import soy.iko.opencode.ui.vmFactory
60import soy.iko.opencode.ui.vmFactory
59
61
@@ -87,7 +89,7 @@ fun SessionListScreen(
87 verticalAlignment = Alignment.CenterVertically,
89 verticalAlignment = Alignment.CenterVertically,
88 ) {
90 ) {
89 Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
91 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))
91 }
93 }
92 DropdownMenu(
94 DropdownMenu(
93 expanded = showServerMenu,
95 expanded = showServerMenu,
@@ -129,16 +131,16 @@ fun SessionListScreen(
129 },
131 },
130 actions = {
132 actions = {
131 IconButton(onClick = { vm.refresh() }) {
133 IconButton(onClick = { vm.refresh() }) {
132− Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
134+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
133 }
135 }
134 IconButton(onClick = onOpenFiles) {
136 IconButton(onClick = onOpenFiles) {
135− Icon(Icons.Filled.Folder, contentDescription = "Files")
137+ Icon(Icons.Filled.Folder, contentDescription = stringResource(R.string.files))
136 }
138 }
137 IconButton(onClick = onOpenSettings) {
139 IconButton(onClick = onOpenSettings) {
138− Icon(Icons.Filled.Settings, contentDescription = "Settings")
140+ Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
139 }
141 }
140 IconButton(onClick = onDisconnect) {
142 IconButton(onClick = onDisconnect) {
141− Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Disconnect")
143+ Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.disconnect))
142 }
144 }
143 },
145 },
144 )
146 )
@@ -147,7 +149,7 @@ fun SessionListScreen(
147 ExtendedFloatingActionButton(
149 ExtendedFloatingActionButton(
148 onClick = { vm.createSession(onCreated = onOpenSession) },
150 onClick = { vm.createSession(onCreated = onOpenSession) },
149 icon = { Icon(Icons.Filled.Add, contentDescription = null) },
151 icon = { Icon(Icons.Filled.Add, contentDescription = null) },
150− text = { Text("New session") },
152+ text = { Text(stringResource(R.string.new_session)) },
151 )
153 )
152 },
154 },
153 ) { padding ->
155 ) { padding ->
@@ -168,14 +170,14 @@ fun SessionListScreen(
168 value = state.query,
170 value = state.query,
169 onValueChange = vm::setQuery,
171 onValueChange = vm::setQuery,
170 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
172 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
171− placeholder = { Text("Search sessions…") },
173+ placeholder = { Text(stringResource(R.string.search_sessions)) },
172 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
174 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
173 singleLine = true,
175 singleLine = true,
174 )
176 )
175 val sessions = state.filtered
177 val sessions = state.filtered
176 if (sessions.isEmpty()) {
178 if (sessions.isEmpty()) {
177 Text(
179 Text(
178− "No sessions match “${state.query}”.",
180+ stringResource(R.string.no_sessions_match, state.query),
179 modifier = Modifier.padding(24.dp),
181 modifier = Modifier.padding(24.dp),
180 color = MaterialTheme.colorScheme.onSurfaceVariant,
182 color = MaterialTheme.colorScheme.onSurfaceVariant,
181 )
183 )
@@ -204,17 +206,17 @@ fun SessionListScreen(
204 pendingDelete?.let { session ->
206 pendingDelete?.let { session ->
205 AlertDialog(
207 AlertDialog(
206 onDismissRequest = { pendingDelete = null },
208 onDismissRequest = { pendingDelete = null },
207− title = { Text("Delete session?") },
209+ title = { Text(stringResource(R.string.delete_session_title)) },
208− text = { Text("“${session.displayTitle}” will be permanently deleted. This cannot be undone.") },
210+ text = { Text(stringResource(R.string.delete_session_text, session.displayTitle)) },
209 confirmButton = {
211 confirmButton = {
210 TextButton(onClick = {
212 TextButton(onClick = {
211 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
213 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
212 pendingDelete = null
214 pendingDelete = null
213 vm.deleteSession(session)
215 vm.deleteSession(session)
214− }) { Text("Delete", color = MaterialTheme.colorScheme.error) }
216+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
215 },
217 },
216 dismissButton = {
218 dismissButton = {
217− TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
219+ TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
218 },
220 },
219 )
221 )
220 }
222 }
@@ -243,21 +245,21 @@ private fun RenameSessionDialog(
243) {
245) {
244 AlertDialog(
246 AlertDialog(
245 onDismissRequest = onDismiss,
247 onDismissRequest = onDismiss,
246− title = { Text("Rename session") },
248+ title = { Text(stringResource(R.string.rename_session)) },
247 text = {
249 text = {
248 OutlinedTextField(
250 OutlinedTextField(
249 value = title,
251 value = title,
250 onValueChange = onTitleChange,
252 onValueChange = onTitleChange,
251 modifier = Modifier.fillMaxWidth(),
253 modifier = Modifier.fillMaxWidth(),
252− placeholder = { Text("Session title") },
254+ placeholder = { Text(stringResource(R.string.session_title_hint)) },
253 singleLine = true,
255 singleLine = true,
254 )
256 )
255 },
257 },
256 confirmButton = {
258 confirmButton = {
257− TextButton(onClick = onConfirm, enabled = title.isNotBlank()) { Text("Save") }
259+ TextButton(onClick = onConfirm, enabled = title.isNotBlank()) { Text(stringResource(R.string.save)) }
258 },
260 },
259 dismissButton = {
261 dismissButton = {
260− TextButton(onClick = onDismiss) { Text("Cancel") }
262+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
261 },
263 },
262 )
264 )
263}
265}
@@ -296,10 +298,10 @@ private fun SessionCard(
296 )
298 )
297 }
299 }
298 IconButton(onClick = onRename) {
300 IconButton(onClick = onRename) {
299− Icon(Icons.Filled.Edit, contentDescription = "Rename")
301+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
300 }
302 }
301 IconButton(onClick = onDelete) {
303 IconButton(onClick = onDelete) {
302− Icon(Icons.Filled.Delete, contentDescription = "Delete")
304+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
303 }
305 }
304 }
306 }
305 if (!preview.isNullOrBlank()) {
307 if (!preview.isNullOrBlank()) {
@@ -329,17 +331,17 @@ private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
329 tint = MaterialTheme.colorScheme.onSurfaceVariant,
331 tint = MaterialTheme.colorScheme.onSurfaceVariant,
330 )
332 )
331 Spacer(Modifier.size(16.dp))
333 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)
333 Spacer(Modifier.size(8.dp))
335 Spacer(Modifier.size(8.dp))
334 Text(
336 Text(
335− "Start a conversation with opencode.",
337+ stringResource(R.string.no_sessions_hint),
336 style = MaterialTheme.typography.bodyMedium,
338 style = MaterialTheme.typography.bodyMedium,
337 color = MaterialTheme.colorScheme.onSurfaceVariant,
339 color = MaterialTheme.colorScheme.onSurfaceVariant,
338 )
340 )
339 Spacer(Modifier.size(20.dp))
341 Spacer(Modifier.size(20.dp))
340 Button(onClick = onCreate) {
342 Button(onClick = onCreate) {
341 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
343 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
342− Text(" New session")
344+ Text(" " + stringResource(R.string.new_session))
343 }
345 }
344 }
346 }
345}
347}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+7−6
@@ -6,6 +6,7 @@ import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.data.model.Session
6import soy.iko.opencode.data.model.Session
7import soy.iko.opencode.data.model.TextPart
7import soy.iko.opencode.data.model.TextPart
8import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.di.AppContainer
9+import soy.iko.opencode.R
9import kotlinx.coroutines.Job
10import kotlinx.coroutines.Job
10import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.SharingStarted
12import kotlinx.coroutines.flow.SharingStarted
@@ -71,7 +72,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
71 fun refresh() {
72 fun refresh() {
72 val conn = container.activeConnection.value
73 val conn = container.activeConnection.value
73 if (conn == null) {
74 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))
75 return
76 return
76 }
77 }
77 _serverLabel.value = conn.profile.displayLabel
78 _serverLabel.value = conn.profile.displayLabel
@@ -83,7 +84,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
83 _state.value = _state.value.copy(sessions = sorted, loading = false)
84 _state.value = _state.value.copy(sessions = sorted, loading = false)
84 loadPreviews(sorted)
85 loadPreviews(sorted)
85 }
86 }
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)) }
87 }
88 }
88 }
89 }
89
90
@@ -119,7 +120,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
119 viewModelScope.launch {
120 viewModelScope.launch {
120 runCatching { conn.repository.createSession() }
121 runCatching { conn.repository.createSession() }
121 .onSuccess { onCreated(it.id); refresh() }
122 .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)) }
123 }
124 }
124 }
125 }
125
126
@@ -128,7 +129,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
128 viewModelScope.launch {
129 viewModelScope.launch {
129 runCatching { conn.repository.deleteSession(session.id) }
130 runCatching { conn.repository.deleteSession(session.id) }
130 .onSuccess { refresh() }
131 .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)) }
132 }
133 }
133 }
134 }
134
135
@@ -139,7 +140,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
139 viewModelScope.launch {
140 viewModelScope.launch {
140 runCatching { conn.api.updateSession(session.id, title) }
141 runCatching { conn.api.updateSession(session.id, title) }
141 .onSuccess { refresh() }
142 .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)) }
143 }
144 }
144 }
145 }
145
146
@@ -158,7 +159,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
158 result.onSuccess { refresh() }
159 result.onSuccess { refresh() }
159 .onFailure {
160 .onFailure {
160 container.disconnect()
161 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))
162 refresh()
163 refresh()
163 }
164 }
164 }
165 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+20−14
@@ -31,11 +31,13 @@ import androidx.compose.runtime.rememberCoroutineScope
31import androidx.compose.ui.Alignment
31import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
32import androidx.compose.ui.Modifier
33import androidx.compose.ui.platform.LocalContext
33import androidx.compose.ui.platform.LocalContext
34+import androidx.compose.ui.res.stringResource
34import androidx.compose.ui.unit.dp
35import androidx.compose.ui.unit.dp
35import androidx.lifecycle.compose.collectAsStateWithLifecycle
36import androidx.lifecycle.compose.collectAsStateWithLifecycle
36import kotlinx.coroutines.launch
37import kotlinx.coroutines.launch
37import soy.iko.opencode.data.repo.ThemeMode
38import soy.iko.opencode.data.repo.ThemeMode
38import soy.iko.opencode.di.AppContainer
39import soy.iko.opencode.di.AppContainer
40+import soy.iko.opencode.R
39
41
40@OptIn(ExperimentalMaterial3Api::class)
42@OptIn(ExperimentalMaterial3Api::class)
41@Composable
43@Composable
@@ -47,23 +49,23 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
47 val context = LocalContext.current
49 val context = LocalContext.current
48 val versionName = remember {
50 val versionName = remember {
49 runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
51 runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
50− .getOrDefault("0.1.0")
52+ .getOrNull() ?: "0.1.0"
51 }
53 }
52
54
53 Scaffold(
55 Scaffold(
54 topBar = {
56 topBar = {
55 TopAppBar(
57 TopAppBar(
56− title = { Text("Settings") },
58+ title = { Text(stringResource(R.string.settings)) },
57 navigationIcon = {
59 navigationIcon = {
58 IconButton(onClick = onBack) {
60 IconButton(onClick = onBack) {
59− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
61+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
60 }
62 }
61 },
63 },
62 )
64 )
63 },
65 },
64 ) { padding ->
66 ) { padding ->
65 Column(modifier = Modifier.padding(padding).padding(16.dp)) {
67 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)
67 ThemeMode.entries.forEach { mode ->
69 ThemeMode.entries.forEach { mode ->
68 ThemeRow(
70 ThemeRow(
69 mode = mode,
71 mode = mode,
@@ -78,9 +80,9 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
78 verticalAlignment = Alignment.CenterVertically,
80 verticalAlignment = Alignment.CenterVertically,
79 ) {
81 ) {
80 Column(modifier = Modifier.weight(1f)) {
82 Column(modifier = Modifier.weight(1f)) {
81− Text("Dynamic color", style = MaterialTheme.typography.bodyLarge)
83+ Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
82 Text(
84 Text(
83− "Use Material You wallpaper colors (Android 12+)",
85+ stringResource(R.string.dynamic_color_desc),
84 style = MaterialTheme.typography.bodySmall,
86 style = MaterialTheme.typography.bodySmall,
85 color = MaterialTheme.colorScheme.onSurfaceVariant,
87 color = MaterialTheme.colorScheme.onSurfaceVariant,
86 )
88 )
@@ -93,17 +95,17 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
93
95
94 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
96 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
95
97
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)
97 if (activeProfile != null) {
99 if (activeProfile != null) {
98 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
100 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
99 Text(
101 Text(
100− activeProfile.baseUrl + if (activeProfile.hasAuth) " • Basic auth" else "",
102+ activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
101 style = MaterialTheme.typography.bodySmall,
103 style = MaterialTheme.typography.bodySmall,
102 color = MaterialTheme.colorScheme.onSurfaceVariant,
104 color = MaterialTheme.colorScheme.onSurfaceVariant,
103 )
105 )
104 } else {
106 } else {
105 Text(
107 Text(
106− "Not connected.",
108+ stringResource(R.string.not_connected),
107 modifier = Modifier.padding(top = 8.dp),
109 modifier = Modifier.padding(top = 8.dp),
108 color = MaterialTheme.colorScheme.onSurfaceVariant,
110 color = MaterialTheme.colorScheme.onSurfaceVariant,
109 )
111 )
@@ -117,20 +119,20 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
117 verticalAlignment = Alignment.CenterVertically,
119 verticalAlignment = Alignment.CenterVertically,
118 ) {
120 ) {
119 Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
121 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))
121 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
123 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
122 }
124 }
123
125
124 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
126 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
125
127
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)
127 Text(
129 Text(
128− "opencode-android $versionName",
130+ stringResource(R.string.about_version, versionName),
129 style = MaterialTheme.typography.bodyMedium,
131 style = MaterialTheme.typography.bodyMedium,
130 modifier = Modifier.padding(top = 8.dp),
132 modifier = Modifier.padding(top = 8.dp),
131 )
133 )
132 Text(
134 Text(
133− "A native Android client for the opencode serve HTTP API.",
135+ stringResource(R.string.about_desc),
134 style = MaterialTheme.typography.bodySmall,
136 style = MaterialTheme.typography.bodySmall,
135 color = MaterialTheme.colorScheme.onSurfaceVariant,
137 color = MaterialTheme.colorScheme.onSurfaceVariant,
136 modifier = Modifier.padding(top = 4.dp),
138 modifier = Modifier.padding(top = 4.dp),
@@ -151,7 +153,11 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
151 ) {
153 ) {
152 RadioButton(selected = selected, onClick = onSelect)
154 RadioButton(selected = selected, onClick = onSelect)
153 Text(
155 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+ },
155 style = MaterialTheme.typography.bodyLarge,
161 style = MaterialTheme.typography.bodyLarge,
156 )
162 )
157 }
163 }
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 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<resources>
3 <string name="app_name">opencode</string>
3 <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>
4</resources>
127</resources>
app/src/main/res/values/themes.xml+10
@@ -1,4 +1,14 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?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+-->
2<resources>
6<resources>
3 <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.NoActionBar" />
7 <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>
4</resources>
14</resources>
gradle/libs.versions.toml+2
@@ -13,10 +13,12 @@ securityCrypto = "1.1.0-alpha06"
13coil = "2.7.0"
13coil = "2.7.0"
14markdownRenderer = "0.27.0"
14markdownRenderer = "0.27.0"
15coreKtx = "1.13.1"
15coreKtx = "1.13.1"
16+splashscreen = "1.0.1"
16junit = "4.13.2"
17junit = "4.13.2"
17
18
18[libraries]
19[libraries]
19androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
20androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
21+androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
20androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
22androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
21androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
23androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
22androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
24androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }