From 68fe7b81d2daab72b705809eb54117a258b7950d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 9 Jul 2026 05:14:14 +0000 Subject: [PATCH] feat: replace mobile shell with native Android app --- apps/mobile/android/.gitignore | 10 - apps/mobile/android/app/build.gradle | 82 +-- .../mobile/android/app/capacitor.build.gradle | 19 - .../myapp/ExampleInstrumentedTest.java | 26 - .../android/app/src/main/AndroidManifest.xml | 26 +- .../molberg/hermesmobile/MainActivity.java | 5 - .../molberg/hermesmobile/MainActivity.kt | 613 ++++++++++++++++++ .../app/src/main/res/layout/activity_main.xml | 12 - .../app/src/main/res/values/colors.xml | 4 + .../app/src/main/res/values/styles.xml | 25 +- .../getcapacitor/myapp/ExampleUnitTest.java | 18 - apps/mobile/android/build.gradle | 32 +- apps/mobile/android/capacitor.settings.gradle | 6 - apps/mobile/android/settings.gradle | 21 +- apps/mobile/android/variables.gradle | 1 - 15 files changed, 692 insertions(+), 208 deletions(-) delete mode 100644 apps/mobile/android/app/capacitor.build.gradle delete mode 100644 apps/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java delete mode 100644 apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.java create mode 100644 apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt delete mode 100644 apps/mobile/android/app/src/main/res/layout/activity_main.xml create mode 100644 apps/mobile/android/app/src/main/res/values/colors.xml delete mode 100644 apps/mobile/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java delete mode 100644 apps/mobile/android/capacitor.settings.gradle diff --git a/apps/mobile/android/.gitignore b/apps/mobile/android/.gitignore index 48354a3..5c0d4cc 100644 --- a/apps/mobile/android/.gitignore +++ b/apps/mobile/android/.gitignore @@ -89,13 +89,3 @@ lint/tmp/ # Android Profiling *.hprof -# Cordova plugins for Capacitor -capacitor-cordova-android-plugins - -# Copied web assets -app/src/main/assets/public - -# Generated Config files -app/src/main/assets/capacitor.config.json -app/src/main/assets/capacitor.plugins.json -app/src/main/res/xml/config.xml diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 7da1198..49c0294 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -1,54 +1,60 @@ -apply plugin: 'com.android.application' +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.compose' +} android { - namespace "cloud.molberg.hermesmobile" - compileSdk rootProject.ext.compileSdkVersion + namespace 'cloud.molberg.hermesmobile' + compileSdk 35 + defaultConfig { - applicationId "cloud.molberg.hermesmobile" - minSdkVersion rootProject.ext.minSdkVersion - targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - aaptOptions { - // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. - // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 - ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' - } + applicationId 'cloud.molberg.hermesmobile' + minSdk 23 + targetSdk 35 + versionCode 2 + versionName '0.2.0-native' + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } + buildTypes { release { minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } -} -repositories { - flatDir{ - dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + compose true } } dependencies { - implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" - implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" - implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" - implementation project(':capacitor-android') - testImplementation "junit:junit:$junitVersion" - androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" - androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" - implementation project(':capacitor-cordova-android-plugins') -} + implementation platform('androidx.compose:compose-bom:2025.01.01') + implementation 'androidx.activity:activity-compose:1.10.0' + implementation 'androidx.compose.animation:animation' + implementation 'androidx.compose.foundation:foundation' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material:material-icons-extended' + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.core:core-ktx:1.15.0' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'org.json:json:20240303' -apply from: 'capacitor.build.gradle' - -try { - def servicesJSON = file('google-services.json') - if (servicesJSON.text) { - apply plugin: 'com.google.gms.google-services' - } -} catch(Exception e) { - logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") + debugImplementation 'androidx.compose.ui:ui-tooling' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' } diff --git a/apps/mobile/android/app/capacitor.build.gradle b/apps/mobile/android/app/capacitor.build.gradle deleted file mode 100644 index 23c73a9..0000000 --- a/apps/mobile/android/app/capacitor.build.gradle +++ /dev/null @@ -1,19 +0,0 @@ -// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN - -android { - compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 - } -} - -apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" -dependencies { - implementation project(':capacitor-haptics') - -} - - -if (hasProperty('postBuildExtras')) { - postBuildExtras() -} diff --git a/apps/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/apps/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java deleted file mode 100644 index f2c2217..0000000 --- a/apps/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.getcapacitor.myapp; - -import static org.junit.Assert.*; - -import android.content.Context; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; -import org.junit.Test; -import org.junit.runner.RunWith; - -/** - * Instrumented test, which will execute on an Android device. - * - * @see Testing documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - - @Test - public void useAppContext() throws Exception { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); - - assertEquals("com.getcapacitor.app", appContext.getPackageName()); - } -} diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index 340e7df..7b3c48a 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -1,41 +1,23 @@ + - - + android:exported="true" + android:screenOrientation="portrait"> - - - - - - - - - diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.java b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.java deleted file mode 100644 index 85740ec..0000000 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.java +++ /dev/null @@ -1,5 +0,0 @@ -package cloud.molberg.hermesmobile; - -import com.getcapacitor.BridgeActivity; - -public class MainActivity extends BridgeActivity {} diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt new file mode 100644 index 0000000..467eb4e --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt @@ -0,0 +1,613 @@ +package cloud.molberg.hermesmobile + +import android.app.Application +import android.content.Context +import android.os.Bundle +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material.icons.outlined.Article +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.Diamond +import androidx.compose.material.icons.outlined.LocalFireDepartment +import androidx.compose.material.icons.outlined.EmojiEvents +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.net.URLEncoder +import java.util.concurrent.TimeUnit + +enum class Screen(val label: String, val icon: ImageVector, val title: String, val kicker: String) { + Quest("Quest", Icons.Filled.ChatBubble, "Quest", "Hermes training path"), + Files("Files", Icons.Filled.Folder, "Backpack", "Workspace loot"), + Terminal("Terminal", Icons.Filled.Terminal, "Power move", "Run a command"), + Status("Status", Icons.Outlined.EmojiEvents, "League", "Companion stats"), + Settings("Settings", Icons.Filled.Settings, "Nest", "Connection setup") +} + +data class FileEntry(val name: String, val path: String, val type: String, val size: Long) +data class ChatMessage(val role: String, val content: String) + +data class HealthState( + val reachable: Boolean = false, + val status: String = "Tap refresh to check companion status.", + val hermesCli: String = "Unknown / unavailable", + val workspace: String = "Unknown", + val uptime: String = "—", + val mode: String = "—" +) + +class CompanionApi(private val context: Context) { + private val prefs = context.getSharedPreferences("companion", Context.MODE_PRIVATE) + private val http = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .build() + + var baseUrl: String + get() = prefs.getString("baseUrl", "http://10.0.2.2:8787") ?: "http://10.0.2.2:8787" + set(value) { prefs.edit().putString("baseUrl", value.trim().ifBlank { "http://10.0.2.2:8787" }).apply() } + + var accessKey: String + get() = prefs.getString("accessKey", "") ?: "" + set(value) { prefs.edit().putString("accessKey", value.trim()).apply() } + + private fun endpoint(path: String): String = baseUrl.trimEnd('/') + path + + private fun request(path: String, method: String = "GET", body: JSONObject? = null): JSONObject { + val mediaType = "application/json; charset=utf-8".toMediaType() + val builder = Request.Builder() + .url(endpoint(path)) + .header("Authorization", "Bearer $accessKey") + .header("Accept", "application/json") + val request = when (method) { + "POST" -> builder.post((body ?: JSONObject()).toString().toRequestBody(mediaType)).build() + "PUT" -> builder.put((body ?: JSONObject()).toString().toRequestBody(mediaType)).build() + else -> builder.get().build() + } + http.newCall(request).execute().use { response -> + val text = response.body?.string().orEmpty() + if (!response.isSuccessful) throw IllegalStateException(text.ifBlank { "HTTP ${response.code}" }) + return if (text.isBlank()) JSONObject() else JSONObject(text) + } + } + + suspend fun health(): HealthState = withContext(Dispatchers.IO) { + val json = request("/api/health") + val checks = json.optJSONObject("checks")?.optJSONObject("hermes") + val config = json.optJSONObject("config") + HealthState( + reachable = true, + status = "Companion is reachable.", + hermesCli = if (checks?.optBoolean("cliAvailable") == true) "Found at ${checks.optString("cliPath")}" else "Unknown / unavailable", + workspace = config?.optString("workspaceRoot") ?: "Unknown", + uptime = "${json.optDouble("uptimeSeconds", 0.0).toInt()}s", + mode = json.optString("mode", "—") + ) + } + + suspend fun validate(): String = withContext(Dispatchers.IO) { + request("/api/auth/validate").optString("message", "Connection works.") + } + + suspend fun chat(prompt: String): String = withContext(Dispatchers.IO) { + request("/api/chat", "POST", JSONObject().put("prompt", prompt)).optString("reply", "") + } + + suspend fun listFiles(path: String): Pair> = withContext(Dispatchers.IO) { + val encoded = URLEncoder.encode(path, "UTF-8") + val json = request("/api/files?path=$encoded") + val entries = json.optJSONArray("entries") ?: JSONArray() + json.optString("path", path) to List(entries.length()) { i -> + val item = entries.getJSONObject(i) + FileEntry( + name = item.optString("name"), + path = item.optString("path"), + type = item.optString("type"), + size = item.optLong("size", 0) + ) + } + } + + suspend fun readFile(path: String): Pair = withContext(Dispatchers.IO) { + val encoded = URLEncoder.encode(path, "UTF-8") + val json = request("/api/files/read?path=$encoded") + json.optString("path", path) to json.optString("content", "") + } + + suspend fun writeFile(path: String, content: String): String = withContext(Dispatchers.IO) { + val json = request("/api/files/write", "PUT", JSONObject().put("path", path).put("content", content)) + "Saved ${json.optLong("bytesWritten")} bytes" + } + + suspend fun runCommand(command: String, cwd: String): String = withContext(Dispatchers.IO) { + val json = request("/api/terminal/run", "POST", JSONObject().put("command", command).put("cwd", cwd)) + listOf( + "$ ${json.optString("command", command)}", + json.optString("stdout"), + json.optString("stderr").takeIf { it.isNotBlank() }?.let { "stderr:\n$it" }.orEmpty(), + "exitCode=${json.opt("exitCode")} timedOut=${json.optBoolean("timedOut")}" + ).filter { it.isNotBlank() }.joinToString("\n") + } +} + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { HermesNativeApp() } + } +} + +@Composable +fun HermesNativeApp() { + val context = LocalContext.current + val api = remember { CompanionApi(context.applicationContext) } + var screen by remember { mutableStateOf(Screen.Quest) } + var online by remember { mutableStateOf(false) } + + LaunchedEffect(screen) { + runCatching { api.health() }.onSuccess { online = true }.onFailure { online = false } + } + + MaterialTheme(colorScheme = MaterialTheme.colorScheme.copy(background = Cream, surface = Paper)) { + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = Cream, + topBar = { NativeTopBar(screen, online) }, + bottomBar = { + NativeBottomBar( + current = screen, + onSelect = { + if (it != screen) vibrate(context) + screen = it + } + ) + } + ) { padding -> + AnimatedContent( + targetState = screen, + transitionSpec = { slideFor(initialState.ordinal, targetState.ordinal) }, + modifier = Modifier + .fillMaxSize() + .padding(padding) + .imePadding(), + label = "screen-slide" + ) { active -> + when (active) { + Screen.Quest -> QuestScreen(api) + Screen.Files -> FilesScreen(api) + Screen.Terminal -> TerminalScreen(api) + Screen.Status -> StatusScreen(api) + Screen.Settings -> SettingsScreen(api) + } + } + } + } +} + +fun slideFor(from: Int, to: Int): ContentTransform { + val direction = if (to > from) 1 else -1 + return slideInHorizontally(animationSpec = tween(260)) { width -> direction * width } togetherWith + slideOutHorizontally(animationSpec = tween(260)) { width -> -direction * width } +} + +fun vibrate(context: Context) { + val vibrator = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + val manager = context.getSystemService(Application.VIBRATOR_MANAGER_SERVICE) as VibratorManager + manager.defaultVibrator + } else { + @Suppress("DEPRECATION") context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) + else @Suppress("DEPRECATION") vibrator.vibrate(20) +} + +@Composable +fun NativeTopBar(screen: Screen, online: Boolean) { + Column( + Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + RaisedCircle(color = Green, shadow = GreenDark, content = "⚡") + Column(Modifier.weight(1f)) { + TinyLabel(screen.kicker) + Text(screen.title, fontSize = 31.sp, fontWeight = FontWeight.Black, color = Ink, lineHeight = 31.sp) + } + StatChip(Icons.Outlined.LocalFireDepartment, "7", Orange) + StatChip(Icons.Outlined.Diamond, "550", Blue) + StatChip(Icons.Filled.Favorite, "3", Red) + } + RaisedCard( + color = if (online) PaleGreen else PaleOrange, + shadow = if (online) GreenShadow else OrangeShadow, + border = if (online) GreenBorder else OrangeBorder, + modifier = Modifier.fillMaxWidth() + ) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(if (online) Icons.Filled.Wifi else Icons.Filled.WifiOff, contentDescription = null, tint = if (online) GreenDark else Orange) + Text(if (online) "Companion online — ready for quests" else "Companion offline — check Settings", color = if (online) GreenDark else Orange, fontWeight = FontWeight.Black) + } + } + } +} + +@Composable +fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) { + Surface(shadowElevation = 14.dp, color = Paper, tonalElevation = 0.dp) { + NavigationBar( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .height(82.dp), + containerColor = Paper, + tonalElevation = 0.dp + ) { + Screen.entries.forEach { item -> + NavigationBarItem( + selected = current == item, + onClick = { onSelect(item) }, + icon = { Icon(item.icon, contentDescription = item.label) }, + label = { Text(item.label, fontWeight = FontWeight.Black, fontSize = 11.sp) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = GreenDark, + selectedTextColor = GreenDark, + indicatorColor = PaleGreen, + unselectedIconColor = Muted, + unselectedTextColor = Muted + ) + ) + } + } + } +} + +@Composable +fun QuestScreen(api: CompanionApi) { + val scope = remember { CoroutineScope(Dispatchers.Main) } + var prompt by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf("") } + val messages = remember { mutableStateListOf() } + + ScreenList { + HeroCard(icon = Icons.Filled.ChatBubble, title = "Give Hermes one clean mission.", label = "Daily quest", text = "Short, direct prompts work best. The native app calls the local companion server.") + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + PathPill(Icons.Filled.CheckCircle, "Check", true) + PathPill(Icons.Outlined.Bolt, "Ask", true, current = true) + PathPill(Icons.Filled.ChatBubble, "Review", false) + } + if (messages.isEmpty()) EmptyLesson() + messages.forEach { Bubble(it) } + if (error.isNotBlank()) ErrorCard(error) + RaisedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + AppTextField(value = prompt, onValue = { prompt = it }, placeholder = "What should Hermes do?", minLines = 3) + PrimaryButton(text = if (busy) "Working…" else "Start quest", icon = Icons.Filled.PlayArrow, enabled = !busy && prompt.isNotBlank()) { + val p = prompt.trim() + prompt = "" + messages.add(ChatMessage("You", p)) + busy = true + error = "" + scope.launch { + runCatching { api.chat(p) } + .onSuccess { messages.add(ChatMessage("Hermes", it)) } + .onFailure { error = it.message ?: "Chat failed" } + busy = false + } + } + } + } + } +} + +@Composable +fun FilesScreen(api: CompanionApi) { + val scope = remember { CoroutineScope(Dispatchers.Main) } + var path by remember { mutableStateOf(".") } + var entries by remember { mutableStateOf>(emptyList()) } + var activePath by remember { mutableStateOf("") } + var draft by remember { mutableStateOf("") } + var status by remember { mutableStateOf("") } + + fun load(next: String = path) = scope.launch { + runCatching { api.listFiles(next) } + .onSuccess { (p, list) -> path = p; entries = list; activePath = ""; draft = ""; status = "" } + .onFailure { status = it.message ?: "Unable to list files" } + } + LaunchedEffect(Unit) { load(".") } + + ScreenList { + CompactQuest(title = "Workspace files", label = "Backpack", text = "Tap folders to go deeper. Files open in a native editor.", icon = Icons.Filled.Folder) { load() } + RaisedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { load(parentPath(path)) }, enabled = path != ".") { Text("Up", fontWeight = FontWeight.Black) } + Text(path, Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, color = Muted, fontWeight = FontWeight.Black) + } + entries.forEach { entry -> + FileRow(entry) { + if (entry.type == "directory") load(entry.path) else scope.launch { + runCatching { api.readFile(entry.path) } + .onSuccess { (p, c) -> activePath = p; draft = c } + .onFailure { status = it.message ?: "Unable to read file" } + } + } + } + } + } + if (activePath.isNotBlank()) RaisedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + TinyLabel("Editing") + Text(activePath, color = Ink, fontWeight = FontWeight.Black, maxLines = 2, overflow = TextOverflow.Ellipsis) + AppTextField(draft, { draft = it }, "File content", minLines = 10) + PrimaryButton("Save", Icons.Filled.Save) { + scope.launch { runCatching { api.writeFile(activePath, draft) }.onSuccess { status = it }.onFailure { status = it.message ?: "Save failed" } } + } + } + } + if (status.isNotBlank()) ErrorCard(status) + } +} + +@Composable +fun TerminalScreen(api: CompanionApi) { + val scope = remember { CoroutineScope(Dispatchers.Main) } + var cwd by remember { mutableStateOf(".") } + var command by remember { mutableStateOf("pwd && ls") } + var output by remember { mutableStateOf("Command output appears here.") } + var busy by remember { mutableStateOf(false) } + + ScreenList { + HeroCard(icon = Icons.Filled.Terminal, title = "Run one safe command.", label = "Power move", text = "Commands execute through the companion server inside the configured workspace.", orbColor = Blue, orbShadow = BlueDark) + RaisedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + FieldLabel("Working directory") + AppTextField(cwd, { cwd = it }, ".") + FieldLabel("Command") + AppTextField(command, { command = it }, "pwd && ls", minLines = 4) + PrimaryButton(if (busy) "Running…" else "Run command", Icons.Filled.PlayArrow, enabled = !busy && command.isNotBlank()) { + busy = true + scope.launch { + runCatching { api.runCommand(command, cwd) }.onSuccess { output = it }.onFailure { output = it.message ?: "Command failed" } + busy = false + } + } + } + } + CodeCard(output) + } +} + +@Composable +fun StatusScreen(api: CompanionApi) { + val scope = remember { CoroutineScope(Dispatchers.Main) } + var health by remember { mutableStateOf(HealthState()) } + fun refresh() = scope.launch { health = runCatching { api.health() }.getOrElse { HealthState(false, it.message ?: "Health check failed") } } + + ScreenList { + CompactQuest(title = "Companion status", label = "League board", text = health.status, icon = Icons.Outlined.EmojiEvents, orbColor = Gold, orbShadow = GoldDark) { refresh() } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Metric("Hermes", if (health.hermesCli.startsWith("Found")) "Ready" else "—", Modifier.weight(1f)) + Metric("Uptime", health.uptime, Modifier.weight(1f)) + Metric("Mode", health.mode, Modifier.weight(1f)) + } + RaisedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + FieldLabel("Companion URL") + Text(api.baseUrl, color = Ink, fontWeight = FontWeight.Black) + FieldLabel("Hermes CLI") + Text(health.hermesCli, color = Ink, fontWeight = FontWeight.Black) + FieldLabel("Workspace") + Text(health.workspace, color = Ink, fontWeight = FontWeight.Black) + } + } + } +} + +@Composable +fun SettingsScreen(api: CompanionApi) { + val scope = remember { CoroutineScope(Dispatchers.Main) } + var baseUrl by remember { mutableStateOf(api.baseUrl) } + var accessKey by remember { mutableStateOf(api.accessKey) } + var status by remember { mutableStateOf("Paste the companion access key, then test the connection.") } + + ScreenList { + CompactQuest(title = "Pair your companion", label = "Nest setup", text = status, icon = Icons.Filled.Settings, orbColor = Green, orbShadow = GreenDark) {} + RaisedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + FieldLabel("Companion server URL") + AppTextField(baseUrl, { baseUrl = it }, "http://10.0.2.2:8787", keyboardType = KeyboardType.Uri) + FieldLabel("Access key") + AppTextField(accessKey, { accessKey = it }, "hm_…", password = true) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) { api.baseUrl = baseUrl; api.accessKey = accessKey; status = "Settings saved." } + PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) { + api.baseUrl = baseUrl; api.accessKey = accessKey; status = "Testing…" + scope.launch { status = runCatching { api.validate() }.getOrElse { it.message ?: "Connection failed" } } + } + } + } + } + } +} + +fun parentPath(path: String): String = path.split('/').filter { it.isNotBlank() }.dropLast(1).joinToString("/").ifBlank { "." } + +@Composable +fun ScreenList(content: @Composable ColumnScope.() -> Unit) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + content = { item { Column(verticalArrangement = Arrangement.spacedBy(14.dp), content = content) } } + ) +} + +@Composable +fun RaisedCard(modifier: Modifier = Modifier, color: Color = Paper, shadow: Color = NeutralShadow, border: Color = NeutralBorder, content: @Composable () -> Unit) { + Box(modifier.padding(bottom = 5.dp)) { + Box(Modifier.matchParentSize().padding(top = 5.dp).clip(RoundedCornerShape(22.dp)).background(shadow)) + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(22.dp), colors = CardDefaults.cardColors(containerColor = color), border = BorderStroke(2.dp, border), elevation = CardDefaults.cardElevation(0.dp)) { content() } + } +} + +@Composable +fun HeroCard(icon: ImageVector, title: String, label: String, text: String, orbColor: Color = Green, orbShadow: Color = GreenDark) { + RaisedCard(Modifier.fillMaxWidth()) { + Row(Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) { + RaisedIcon(icon, orbColor, orbShadow) + Column(Modifier.weight(1f)) { TinyLabel(label); Text(title, color = Ink, fontWeight = FontWeight.Black, fontSize = 28.sp, lineHeight = 29.sp); Text(text, color = Muted, fontWeight = FontWeight.ExtraBold, lineHeight = 18.sp) } + } + } +} + +@Composable +fun CompactQuest(title: String, label: String, text: String, icon: ImageVector, orbColor: Color = Green, orbShadow: Color = GreenDark, onRefresh: () -> Unit) { + RaisedCard(Modifier.fillMaxWidth()) { + Row(Modifier.padding(14.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + RaisedIcon(icon, orbColor, orbShadow, size = 58) + Column(Modifier.weight(1f)) { TinyLabel(label); Text(title, color = Ink, fontWeight = FontWeight.Black, fontSize = 24.sp); Text(text, color = Muted, fontWeight = FontWeight.ExtraBold, lineHeight = 17.sp) } + if (onRefresh !== {}) RoundButton(Icons.Filled.Refresh, onRefresh) + } + } +} + +@Composable fun RaisedIcon(icon: ImageVector, color: Color, shadow: Color, size: Int = 66) { Box(Modifier.size(size.dp), contentAlignment = Alignment.Center) { Box(Modifier.matchParentSize().padding(top = 5.dp).clip(CircleShape).background(shadow)); Box(Modifier.matchParentSize().padding(bottom = 5.dp).clip(CircleShape).background(color), contentAlignment = Alignment.Center) { Icon(icon, null, tint = Color.White, modifier = Modifier.size((size / 2).dp)) } } } +@Composable fun RaisedCircle(color: Color, shadow: Color, content: String) { Box(Modifier.size(54.dp), contentAlignment = Alignment.Center) { Box(Modifier.matchParentSize().padding(top = 5.dp).clip(RoundedCornerShape(18.dp)).background(shadow)); Box(Modifier.matchParentSize().padding(bottom = 5.dp).clip(RoundedCornerShape(18.dp)).background(color), contentAlignment = Alignment.Center) { Text(content, fontSize = 24.sp) } } } +@Composable fun StatChip(icon: ImageVector, value: String, color: Color) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(Paper).padding(horizontal = 7.dp, vertical = 3.dp)) { Icon(icon, null, tint = color, modifier = Modifier.size(15.dp)); Text(value, color = color, fontWeight = FontWeight.Black, fontSize = 12.sp) } } +@Composable fun TinyLabel(text: String) { Text(text.uppercase(), color = Muted, fontSize = 11.sp, fontWeight = FontWeight.Black, letterSpacing = 0.7.sp) } +@Composable fun FieldLabel(text: String) { Text(text.uppercase(), color = Muted, fontSize = 12.sp, fontWeight = FontWeight.Black) } +@Composable fun PathPill(icon: ImageVector, text: String, done: Boolean, current: Boolean = false) { Row(Modifier.clip(RoundedCornerShape(99.dp)).background(if (current) Green else if (done) PaleGreen else Surface).padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { Icon(icon, null, tint = if (current) Color.White else GreenDark, modifier = Modifier.size(18.dp)); Text(text, color = if (current) Color.White else GreenDark, fontWeight = FontWeight.Black, fontSize = 13.sp) } } +@Composable fun EmptyLesson() { RaisedCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text("🪽", fontSize = 42.sp); Text("No mission yet", color = Ink, fontWeight = FontWeight.Black, fontSize = 20.sp); Text("Try: check PM2 status or summarize logs.", color = Muted, fontWeight = FontWeight.ExtraBold) } } } +@Composable fun Bubble(msg: ChatMessage) { RaisedCard(Modifier.fillMaxWidth().padding(start = if (msg.role == "You") 28.dp else 0.dp, end = if (msg.role == "You") 0.dp else 28.dp), color = if (msg.role == "You") PaleGreen else PaleGold) { Column(Modifier.padding(12.dp)) { TinyLabel(msg.role); Text(msg.content, color = Ink, fontWeight = FontWeight.ExtraBold, lineHeight = 19.sp) } } } +@Composable fun ErrorCard(text: String) { RaisedCard(Modifier.fillMaxWidth(), color = PaleRed, shadow = RedShadow, border = RedBorder) { Text(text, Modifier.padding(12.dp), color = Red, fontWeight = FontWeight.Black) } } +@Composable fun CodeCard(text: String) { RaisedCard(Modifier.fillMaxWidth()) { Text(text, Modifier.padding(14.dp), color = BlueDark, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontSize = 13.sp, lineHeight = 18.sp) } } +@Composable fun FileRow(entry: FileEntry, onClick: () -> Unit) { RaisedCard(Modifier.fillMaxWidth()) { Row(Modifier.clickable(onClick = onClick).padding(10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Box(Modifier.size(42.dp).clip(RoundedCornerShape(14.dp)).background(if (entry.type == "directory") PaleBlue else PaleGold), contentAlignment = Alignment.Center) { Icon(if (entry.type == "directory") Icons.Filled.Folder else Icons.Outlined.Article, null, tint = if (entry.type == "directory") Blue else GoldDark) }; Text(entry.name, Modifier.weight(1f), color = Ink, fontWeight = FontWeight.Black, maxLines = 1, overflow = TextOverflow.Ellipsis); Text(if (entry.type == "file") "${entry.size} B" else "dir", color = Muted, fontWeight = FontWeight.Black, fontSize = 12.sp) } } } +@Composable fun Metric(label: String, value: String, modifier: Modifier = Modifier) { RaisedCard(modifier) { Column(Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text(value, color = GreenDark, fontWeight = FontWeight.Black, fontSize = 18.sp, maxLines = 1, overflow = TextOverflow.Ellipsis); Text(label.uppercase(), color = Muted, fontWeight = FontWeight.Black, fontSize = 10.sp) } } } +@Composable fun AppTextField(value: String, onValue: (String) -> Unit, placeholder: String, minLines: Int = 1, keyboardType: KeyboardType = KeyboardType.Text, password: Boolean = false) { OutlinedTextField(value = value, onValueChange = onValue, modifier = Modifier.fillMaxWidth(), placeholder = { Text(placeholder, color = Muted) }, minLines = minLines, keyboardOptions = KeyboardOptions(keyboardType = keyboardType), visualTransformation = if (password) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None, shape = RoundedCornerShape(16.dp), colors = TextFieldDefaults.colors(focusedContainerColor = Surface, unfocusedContainerColor = Surface, focusedIndicatorColor = Green, unfocusedIndicatorColor = NeutralBorder, cursorColor = GreenDark)) } +@Composable fun PrimaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { Button(onClick = onClick, enabled = enabled, modifier = modifier.height(54.dp), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = Green, contentColor = Color.White, disabledContainerColor = NeutralBorder), elevation = ButtonDefaults.buttonElevation(defaultElevation = 6.dp, pressedElevation = 1.dp)) { Text(text, fontWeight = FontWeight.Black); Spacer(Modifier.width(6.dp)); Icon(icon, null) } } +@Composable fun SecondaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) { Button(onClick = onClick, modifier = modifier.height(54.dp), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = Surface, contentColor = Ink), elevation = ButtonDefaults.buttonElevation(defaultElevation = 4.dp, pressedElevation = 1.dp)) { Text(text, fontWeight = FontWeight.Black); Spacer(Modifier.width(6.dp)); Icon(icon, null) } } +@Composable fun RoundButton(icon: ImageVector, onClick: () -> Unit) { Button(onClick = onClick, modifier = Modifier.size(50.dp), shape = RoundedCornerShape(16.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp), colors = ButtonDefaults.buttonColors(containerColor = Blue, contentColor = Color.White), elevation = ButtonDefaults.buttonElevation(defaultElevation = 5.dp, pressedElevation = 1.dp)) { Icon(icon, null) } } + +val Green = Color(0xFF58CC02) +val GreenDark = Color(0xFF46A302) +val GreenShadow = Color(0xFFB8E9A4) +val GreenBorder = Color(0xFF8DDB6C) +val PaleGreen = Color(0xFFE8F8DF) +val Blue = Color(0xFF1CB0F6) +val BlueDark = Color(0xFF1687C2) +val PaleBlue = Color(0xFFE2F3FF) +val Gold = Color(0xFFFFC800) +val GoldDark = Color(0xFFC99500) +val PaleGold = Color(0xFFFFF6D6) +val Orange = Color(0xFFFF9600) +val PaleOrange = Color(0xFFFFF0D8) +val OrangeBorder = Color(0xFFE7BE82) +val OrangeShadow = Color(0xFFE4C99D) +val Red = Color(0xFFFF4B4B) +val PaleRed = Color(0xFFFFE4E4) +val RedBorder = Color(0xFFFFB8B8) +val RedShadow = Color(0xFFE9B4B4) +val Cream = Color(0xFFF2F7E8) +val Paper = Color(0xFFFFFEF8) +val Surface = Color(0xFFF3F8ED) +val Ink = Color(0xFF243B2E) +val Muted = Color(0xFF6D7C72) +val NeutralBorder = Color(0xFFDCE8D5) +val NeutralShadow = Color(0xFFC8D8C0) diff --git a/apps/mobile/android/app/src/main/res/layout/activity_main.xml b/apps/mobile/android/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index b5ad138..0000000 --- a/apps/mobile/android/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/apps/mobile/android/app/src/main/res/values/colors.xml b/apps/mobile/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..20f86bc --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #58CC02 + diff --git a/apps/mobile/android/app/src/main/res/values/styles.xml b/apps/mobile/android/app/src/main/res/values/styles.xml index be874e5..c471cc5 100644 --- a/apps/mobile/android/app/src/main/res/values/styles.xml +++ b/apps/mobile/android/app/src/main/res/values/styles.xml @@ -1,22 +1,9 @@ - - - - - - - - - \ No newline at end of file + diff --git a/apps/mobile/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/apps/mobile/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java deleted file mode 100644 index 0297327..0000000 --- a/apps/mobile/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.getcapacitor.myapp; - -import static org.junit.Assert.*; - -import org.junit.Test; - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see Testing documentation - */ -public class ExampleUnitTest { - - @Test - public void addition_isCorrect() throws Exception { - assertEquals(4, 2 + 2); - } -} diff --git a/apps/mobile/android/build.gradle b/apps/mobile/android/build.gradle index f1b3b0e..bbf0e6a 100644 --- a/apps/mobile/android/build.gradle +++ b/apps/mobile/android/build.gradle @@ -1,29 +1,5 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. - -buildscript { - - repositories { - google() - mavenCentral() - } - dependencies { - classpath 'com.android.tools.build:gradle:8.7.2' - classpath 'com.google.gms:google-services:4.4.2' - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -} - -apply from: "variables.gradle" - -allprojects { - repositories { - google() - mavenCentral() - } -} - -task clean(type: Delete) { - delete rootProject.buildDir +plugins { + id 'com.android.application' version '8.7.2' apply false + id 'org.jetbrains.kotlin.android' version '2.0.21' apply false + id 'org.jetbrains.kotlin.plugin.compose' version '2.0.21' apply false } diff --git a/apps/mobile/android/capacitor.settings.gradle b/apps/mobile/android/capacitor.settings.gradle deleted file mode 100644 index 30260dc..0000000 --- a/apps/mobile/android/capacitor.settings.gradle +++ /dev/null @@ -1,6 +0,0 @@ -// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN -include ':capacitor-android' -project(':capacitor-android').projectDir = new File('../../../node_modules/@capacitor/android/capacitor') - -include ':capacitor-haptics' -project(':capacitor-haptics').projectDir = new File('../../../node_modules/@capacitor/haptics/android') diff --git a/apps/mobile/android/settings.gradle b/apps/mobile/android/settings.gradle index 3b4431d..fe9bf8d 100644 --- a/apps/mobile/android/settings.gradle +++ b/apps/mobile/android/settings.gradle @@ -1,5 +1,18 @@ -include ':app' -include ':capacitor-cordova-android-plugins' -project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -apply from: 'capacitor.settings.gradle' \ No newline at end of file +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = 'Hermes Mobile' +include ':app' diff --git a/apps/mobile/android/variables.gradle b/apps/mobile/android/variables.gradle index 2c8e408..1b45dac 100644 --- a/apps/mobile/android/variables.gradle +++ b/apps/mobile/android/variables.gradle @@ -12,5 +12,4 @@ ext { junitVersion = '4.13.2' androidxJunitVersion = '1.2.1' androidxEspressoCoreVersion = '3.6.1' - cordovaAndroidVersion = '10.1.1' } \ No newline at end of file