From d3d7ef6ebc401dac0925f39a223e39c5e58fc384 Mon Sep 17 00:00:00 2001 From: Kaarel Kaljurand Date: Sun, 8 Jun 2025 21:53:02 +0000 Subject: [PATCH] Add Jetpack Compose navigation and Room data model for combo/service management UI --- .../ee/ioc/phon/android/speak/MainActivity.kt | 37 ++++++++ .../ioc/phon/android/speak/model/ComboDaos.kt | 54 +++++++++++ .../phon/android/speak/model/ComboDatabase.kt | 14 +++ .../phon/android/speak/model/ComboEntities.kt | 31 +++++++ .../android/speak/model/ComboRepository.kt | 29 ++++++ .../ioc/phon/android/speak/ui/AppNavHost.kt | 61 +++++++++++++ .../android/speak/ui/ComboDetailsScreen.kt | 91 +++++++++++++++++++ .../phon/android/speak/ui/ComboListScreen.kt | 84 +++++++++++++++++ .../android/speak/ui/ServicePickerScreen.kt | 67 ++++++++++++++ .../speak/viewmodel/ComboViewModels.kt | 69 ++++++++++++++ 10 files changed, 537 insertions(+) create mode 100644 app/src/main/java/ee/ioc/phon/android/speak/MainActivity.kt create mode 100644 app/src/main/java/ee/ioc/phon/android/speak/model/ComboDaos.kt create mode 100644 app/src/main/java/ee/ioc/phon/android/speak/model/ComboDatabase.kt create mode 100644 app/src/main/java/ee/ioc/phon/android/speak/model/ComboEntities.kt create mode 100644 app/src/main/java/ee/ioc/phon/android/speak/model/ComboRepository.kt create mode 100644 app/src/main/java/ee/ioc/phon/android/speak/ui/AppNavHost.kt create mode 100644 app/src/main/java/ee/ioc/phon/android/speak/ui/ComboDetailsScreen.kt create mode 100644 app/src/main/java/ee/ioc/phon/android/speak/ui/ComboListScreen.kt create mode 100644 app/src/main/java/ee/ioc/phon/android/speak/ui/ServicePickerScreen.kt create mode 100644 app/src/main/java/ee/ioc/phon/android/speak/viewmodel/ComboViewModels.kt diff --git a/app/src/main/java/ee/ioc/phon/android/speak/MainActivity.kt b/app/src/main/java/ee/ioc/phon/android/speak/MainActivity.kt new file mode 100644 index 00000000..25c6c5b4 --- /dev/null +++ b/app/src/main/java/ee/ioc/phon/android/speak/MainActivity.kt @@ -0,0 +1,37 @@ +package ee.ioc.phon.android.speak + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import ee.ioc.phon.android.speak.model.ComboDao +import ee.ioc.phon.android.speak.model.ComboListDao +import ee.ioc.phon.android.speak.model.ServiceDao +import ee.ioc.phon.android.speak.model.ComboRepository +import ee.ioc.phon.android.speak.ui.AppNavHost + +// TODO: Replace with your actual Room database class +import androidx.room.Room +import ee.ioc.phon.android.speak.model.ComboDatabase + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Initialize Room database (replace ComboDatabase::class.java with your actual database class) + val db = Room.databaseBuilder( + applicationContext, + ComboDatabase::class.java, + "combo-db" + ).build() + val repository = ComboRepository( + db.serviceDao(), + db.comboDao(), + db.comboListDao() + ) + setContent { + MaterialTheme { + AppNavHost(repository = repository) + } + } + } +} diff --git a/app/src/main/java/ee/ioc/phon/android/speak/model/ComboDaos.kt b/app/src/main/java/ee/ioc/phon/android/speak/model/ComboDaos.kt new file mode 100644 index 00000000..81c9bfe9 --- /dev/null +++ b/app/src/main/java/ee/ioc/phon/android/speak/model/ComboDaos.kt @@ -0,0 +1,54 @@ +package ee.ioc.phon.android.speak.model + +import androidx.room.* + +@Dao +interface ServiceDao { + @Query("SELECT * FROM services") + suspend fun getAll(): List + + @Query("SELECT * FROM services WHERE id = :id") + suspend fun getById(id: String): ServiceEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(service: ServiceEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(services: List) + + @Delete + suspend fun delete(service: ServiceEntity) +} + +@Dao +interface ComboDao { + @Query("SELECT * FROM combos WHERE comboListType = :type ORDER BY comboId") + suspend fun getByListType(type: String): List + + @Query("SELECT * FROM combos WHERE comboId = :comboId") + suspend fun getById(comboId: Long): ComboEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(combo: ComboEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(combos: List) + + @Update + suspend fun update(combo: ComboEntity) + + @Delete + suspend fun delete(combo: ComboEntity) +} + +@Dao +interface ComboListDao { + @Query("SELECT * FROM combo_lists WHERE type = :type") + suspend fun getByType(type: String): ComboListEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(comboList: ComboListEntity) + + @Update + suspend fun update(comboList: ComboListEntity) +} diff --git a/app/src/main/java/ee/ioc/phon/android/speak/model/ComboDatabase.kt b/app/src/main/java/ee/ioc/phon/android/speak/model/ComboDatabase.kt new file mode 100644 index 00000000..c31942fa --- /dev/null +++ b/app/src/main/java/ee/ioc/phon/android/speak/model/ComboDatabase.kt @@ -0,0 +1,14 @@ +package ee.ioc.phon.android.speak.model + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [ServiceEntity::class, ComboEntity::class, ComboListEntity::class], + version = 1 +) +abstract class ComboDatabase : RoomDatabase() { + abstract fun serviceDao(): ServiceDao + abstract fun comboDao(): ComboDao + abstract fun comboListDao(): ComboListDao +} diff --git a/app/src/main/java/ee/ioc/phon/android/speak/model/ComboEntities.kt b/app/src/main/java/ee/ioc/phon/android/speak/model/ComboEntities.kt new file mode 100644 index 00000000..890d6bde --- /dev/null +++ b/app/src/main/java/ee/ioc/phon/android/speak/model/ComboEntities.kt @@ -0,0 +1,31 @@ +package ee.ioc.phon.android.speak.model + +import androidx.room.* + +@Entity(tableName = "services") +data class ServiceEntity( + @PrimaryKey val id: String, // ComponentName flattened string + val name: String, + val description: String?, + val iconRes: String?, // Could be a URI or resource name + val settingsActivity: String? +) + +@Entity(tableName = "combos") +data class ComboEntity( + @PrimaryKey(autoGenerate = true) val comboId: Long = 0, + val serviceId: String, // Foreign key to ServiceEntity + val shortLabel: String, + val longLabel: String, + val tinyLabel: String, + val enabled: Boolean = true, + val inputLanguage: String?, + val extras: String?, // JSON-encoded map of RecognizerIntent and service-specific extras + val comboListType: String // "IME" or "PANEL" +) + +@Entity(tableName = "combo_lists") +data class ComboListEntity( + @PrimaryKey val type: String, // "IME" or "PANEL" + val order: String // JSON-encoded list of comboIds in order +) diff --git a/app/src/main/java/ee/ioc/phon/android/speak/model/ComboRepository.kt b/app/src/main/java/ee/ioc/phon/android/speak/model/ComboRepository.kt new file mode 100644 index 00000000..77c26155 --- /dev/null +++ b/app/src/main/java/ee/ioc/phon/android/speak/model/ComboRepository.kt @@ -0,0 +1,29 @@ +package ee.ioc.phon.android.speak.model + +import kotlinx.coroutines.flow.Flow + +class ComboRepository( + private val serviceDao: ServiceDao, + private val comboDao: ComboDao, + private val comboListDao: ComboListDao +) { + // Service operations + suspend fun getAllServices(): List = serviceDao.getAll() + suspend fun getServiceById(id: String): ServiceEntity? = serviceDao.getById(id) + suspend fun insertService(service: ServiceEntity) = serviceDao.insert(service) + suspend fun insertServices(services: List) = serviceDao.insertAll(services) + suspend fun deleteService(service: ServiceEntity) = serviceDao.delete(service) + + // Combo operations + suspend fun getCombosByListType(type: String): List = comboDao.getByListType(type) + suspend fun getComboById(comboId: Long): ComboEntity? = comboDao.getById(comboId) + suspend fun insertCombo(combo: ComboEntity): Long = comboDao.insert(combo) + suspend fun insertCombos(combos: List) = comboDao.insertAll(combos) + suspend fun updateCombo(combo: ComboEntity) = comboDao.update(combo) + suspend fun deleteCombo(combo: ComboEntity) = comboDao.delete(combo) + + // ComboList operations + suspend fun getComboListByType(type: String): ComboListEntity? = comboListDao.getByType(type) + suspend fun insertComboList(comboList: ComboListEntity) = comboListDao.insert(comboList) + suspend fun updateComboList(comboList: ComboListEntity) = comboListDao.update(comboList) +} diff --git a/app/src/main/java/ee/ioc/phon/android/speak/ui/AppNavHost.kt b/app/src/main/java/ee/ioc/phon/android/speak/ui/AppNavHost.kt new file mode 100644 index 00000000..3e0d05b3 --- /dev/null +++ b/app/src/main/java/ee/ioc/phon/android/speak/ui/AppNavHost.kt @@ -0,0 +1,61 @@ +package ee.ioc.phon.android.speak.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import ee.ioc.phon.android.speak.model.ComboRepository +import ee.ioc.phon.android.speak.viewmodel.ComboDetailsViewModel +import ee.ioc.phon.android.speak.viewmodel.ComboListViewModel +import ee.ioc.phon.android.speak.viewmodel.ServicePickerViewModel + +sealed class Screen(val route: String) { + object ComboList : Screen("comboList") + object ServicePicker : Screen("servicePicker") + data class ComboDetails(val comboId: Long) : Screen("comboDetails/{comboId}") { + companion object { + fun createRoute(comboId: Long) = "comboDetails/$comboId" + } + } +} + +@Composable +fun AppNavHost( + repository: ComboRepository, + startDestination: String = Screen.ComboList.route +) { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = startDestination) { + composable(Screen.ComboList.route) { + val viewModel = remember { ComboListViewModel(repository, "IME") } + ComboListScreen( + viewModel = viewModel, + onComboClick = { combo -> navController.navigate(Screen.ComboDetails.createRoute(combo.comboId)) }, + onCloneCombo = { /* TODO: implement clone */ }, + onRemoveCombo = { combo -> viewModel.removeCombo(combo) }, + onAddCombo = { navController.navigate(Screen.ServicePicker.route) }, + onLoadCombos = { /* TODO: implement load */ }, + onSaveCombos = { /* TODO: implement save */ } + ) + } + composable(Screen.ServicePicker.route) { + val viewModel = remember { ServicePickerViewModel(repository) } + ServicePickerScreen( + viewModel = viewModel, + onServiceSelected = { /* TODO: handle service selection */ }, + onConfigureService = { /* TODO: handle service config */ } + ) + } + composable("comboDetails/{comboId}") { backStackEntry -> + val comboId = backStackEntry.arguments?.getString("comboId")?.toLongOrNull() ?: return@composable + val viewModel = remember { ComboDetailsViewModel(repository, comboId) } + ComboDetailsScreen( + viewModel = viewModel, + onSave = { /* TODO: handle save */ }, + onConfigureService = { /* TODO: handle config */ } + ) + } + } +} diff --git a/app/src/main/java/ee/ioc/phon/android/speak/ui/ComboDetailsScreen.kt b/app/src/main/java/ee/ioc/phon/android/speak/ui/ComboDetailsScreen.kt new file mode 100644 index 00000000..31b87d51 --- /dev/null +++ b/app/src/main/java/ee/ioc/phon/android/speak/ui/ComboDetailsScreen.kt @@ -0,0 +1,91 @@ +package ee.ioc.phon.android.speak.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import ee.ioc.phon.android.speak.model.ComboEntity +import ee.ioc.phon.android.speak.viewmodel.ComboDetailsViewModel + +@Composable +fun ComboDetailsScreen( + viewModel: ComboDetailsViewModel, + onSave: (ComboEntity) -> Unit, + onConfigureService: () -> Unit +) { + val comboState = viewModel.combo.collectAsState() + val combo = comboState.value ?: return + + var shortLabel by remember { mutableStateOf(TextFieldValue(combo.shortLabel)) } + var longLabel by remember { mutableStateOf(TextFieldValue(combo.longLabel)) } + var tinyLabel by remember { mutableStateOf(TextFieldValue(combo.tinyLabel)) } + var inputLanguage by remember { mutableStateOf(TextFieldValue(combo.inputLanguage ?: "")) } + var enabled by remember { mutableStateOf(combo.enabled) } + var extras by remember { mutableStateOf(TextFieldValue(combo.extras ?: "")) } + + Scaffold( + topBar = { + TopAppBar(title = { Text("Combo Details") }, actions = { + IconButton(onClick = onConfigureService) { + Icon(Icons.Default.Settings, contentDescription = "Configure Service") + } + }) + }, + floatingActionButton = { + FloatingActionButton(onClick = { + val updatedCombo = combo.copy( + shortLabel = shortLabel.text, + longLabel = longLabel.text, + tinyLabel = tinyLabel.text, + inputLanguage = inputLanguage.text, + enabled = enabled, + extras = extras.text + ) + onSave(updatedCombo) + }) { + Icon(Icons.Default.Settings, contentDescription = "Save") + } + } + ) { padding -> + Column(modifier = Modifier.padding(padding).padding(16.dp)) { + OutlinedTextField( + value = shortLabel, + onValueChange = { shortLabel = it }, + label = { Text("Short Label") }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = longLabel, + onValueChange = { longLabel = it }, + label = { Text("Long Label") }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = tinyLabel, + onValueChange = { tinyLabel = it }, + label = { Text("Tiny Label") }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = inputLanguage, + onValueChange = { inputLanguage = it }, + label = { Text("Input Language") }, + modifier = Modifier.fillMaxWidth() + ) + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Checkbox(checked = enabled, onCheckedChange = { enabled = it }) + Text("Enabled") + } + OutlinedTextField( + value = extras, + onValueChange = { extras = it }, + label = { Text("EXTRAs (JSON)") }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} diff --git a/app/src/main/java/ee/ioc/phon/android/speak/ui/ComboListScreen.kt b/app/src/main/java/ee/ioc/phon/android/speak/ui/ComboListScreen.kt new file mode 100644 index 00000000..d540c051 --- /dev/null +++ b/app/src/main/java/ee/ioc/phon/android/speak/ui/ComboListScreen.kt @@ -0,0 +1,84 @@ +package ee.ioc.phon.android.speak.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import ee.ioc.phon.android.speak.model.ComboEntity +import ee.ioc.phon.android.speak.viewmodel.ComboListViewModel + +@Composable +fun ComboListScreen( + viewModel: ComboListViewModel, + onComboClick: (ComboEntity) -> Unit, + onCloneCombo: (ComboEntity) -> Unit, + onRemoveCombo: (ComboEntity) -> Unit, + onAddCombo: () -> Unit, + onLoadCombos: () -> Unit, + onSaveCombos: () -> Unit +) { + val combos = viewModel.combos.collectAsState() + Scaffold( + topBar = { + TopAppBar(title = { Text("Current Combos") }, actions = { + IconButton(onClick = onAddCombo) { Icon(Icons.Default.Add, contentDescription = "Add") } + IconButton(onClick = onLoadCombos) { Icon(Icons.Default.FileUpload, contentDescription = "Load") } + IconButton(onClick = onSaveCombos) { Icon(Icons.Default.Save, contentDescription = "Save") } + }) + } + ) { padding -> + LazyColumn(modifier = Modifier.padding(padding)) { + items(combos.value.size) { idx -> + val combo = combos.value[idx] + ComboListItem( + combo = combo, + onClick = { onComboClick(combo) }, + onClone = { onCloneCombo(combo) }, + onRemove = { onRemoveCombo(combo) } + ) + } + } + } +} + +@Composable +fun ComboListItem( + combo: ComboEntity, + onClick: () -> Unit, + onClone: () -> Unit, + onRemove: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(if (combo.enabled) Color.Transparent else Color.LightGray) + .clickable { onClick() } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // TODO: Load service icon + Icon(Icons.Default.Edit, contentDescription = null) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(combo.shortLabel, fontWeight = FontWeight.Bold) + Text(combo.longLabel, style = MaterialTheme.typography.bodySmall) + combo.inputLanguage?.let { Text("Lang: $it", style = MaterialTheme.typography.bodySmall) } + } + IconButton(onClick = onClone) { Icon(Icons.Default.Add, contentDescription = "Clone") } + IconButton(onClick = onRemove) { Icon(Icons.Default.Delete, contentDescription = "Remove") } + } +} diff --git a/app/src/main/java/ee/ioc/phon/android/speak/ui/ServicePickerScreen.kt b/app/src/main/java/ee/ioc/phon/android/speak/ui/ServicePickerScreen.kt new file mode 100644 index 00000000..c0c56933 --- /dev/null +++ b/app/src/main/java/ee/ioc/phon/android/speak/ui/ServicePickerScreen.kt @@ -0,0 +1,67 @@ +package ee.ioc.phon.android.speak.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import ee.ioc.phon.android.speak.model.ServiceEntity +import ee.ioc.phon.android.speak.viewmodel.ServicePickerViewModel + +@Composable +fun ServicePickerScreen( + viewModel: ServicePickerViewModel, + onServiceSelected: (ServiceEntity) -> Unit, + onConfigureService: (ServiceEntity) -> Unit +) { + val services = viewModel.services.collectAsState() + Scaffold( + topBar = { + TopAppBar(title = { Text("Recognition Services") }) + } + ) { padding -> + LazyColumn(modifier = Modifier.padding(padding)) { + items(services.value.size) { idx -> + val service = services.value[idx] + ServiceListItem( + service = service, + onClick = { onServiceSelected(service) }, + onConfigure = { onConfigureService(service) } + ) + } + } + } +} + +@Composable +fun ServiceListItem( + service: ServiceEntity, + onClick: () -> Unit, + onConfigure: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // TODO: Load icon from service.iconRes + Icon(Icons.Default.Settings, contentDescription = null) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(service.name, fontWeight = FontWeight.Bold) + service.description?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + } + TextButton(onClick = onConfigure) { + Text("Configure") + } + } +} diff --git a/app/src/main/java/ee/ioc/phon/android/speak/viewmodel/ComboViewModels.kt b/app/src/main/java/ee/ioc/phon/android/speak/viewmodel/ComboViewModels.kt new file mode 100644 index 00000000..c0d52b17 --- /dev/null +++ b/app/src/main/java/ee/ioc/phon/android/speak/viewmodel/ComboViewModels.kt @@ -0,0 +1,69 @@ +package ee.ioc.phon.android.speak.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ee.ioc.phon.android.speak.model.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class ServicePickerViewModel(private val repository: ComboRepository) : ViewModel() { + private val _services = MutableStateFlow>(emptyList()) + val services: StateFlow> = _services + + fun loadServices() { + viewModelScope.launch { + _services.value = repository.getAllServices() + } + } +} + +class ComboListViewModel(private val repository: ComboRepository, private val listType: String) : ViewModel() { + private val _combos = MutableStateFlow>(emptyList()) + val combos: StateFlow> = _combos + + fun loadCombos() { + viewModelScope.launch { + _combos.value = repository.getCombosByListType(listType) + } + } + + fun addCombo(combo: ComboEntity) { + viewModelScope.launch { + repository.insertCombo(combo) + loadCombos() + } + } + + fun removeCombo(combo: ComboEntity) { + viewModelScope.launch { + repository.deleteCombo(combo) + loadCombos() + } + } + + fun updateCombo(combo: ComboEntity) { + viewModelScope.launch { + repository.updateCombo(combo) + loadCombos() + } + } +} + +class ComboDetailsViewModel(private val repository: ComboRepository, private val comboId: Long) : ViewModel() { + private val _combo = MutableStateFlow(null) + val combo: StateFlow = _combo + + fun loadCombo() { + viewModelScope.launch { + _combo.value = repository.getComboById(comboId) + } + } + + fun updateCombo(combo: ComboEntity) { + viewModelScope.launch { + repository.updateCombo(combo) + _combo.value = combo + } + } +}