diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt new file mode 100644 index 00000000..b02663ca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt @@ -0,0 +1,309 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Title +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.view.model.AddDialectViewModel +import press.mantra.compose.ui.view.state.AddDialectUIState + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AddDialectScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateToRoute: (Route) -> Unit, +) { + val addDialectViewModel: AddDialectViewModel = viewModel( + factory = AddDialectViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddDialectUIState = initialAddDialectUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + activeUserPublicKey = activeUserPublicKey, + mantraRepository = mantraRepository + ) + ) + + when (val addDialectUIState = addDialectViewModel.addDialectUIState) { + is AddDialectUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(50.dp) + ) + Text( + text = addDialectUIState.message, + ) + } + } + is AddDialectUIState.Loaded -> { + val nameFieldState = rememberTextFieldState() + val countryFieldState = rememberTextFieldState() + val languageFieldState = rememberTextFieldState() + + Scaffold( + topBar = { + TopAppBar( + title = { + addDialectUIState.localChatRoom.RenderChatRoomTitleText() + }, + actions = { + + } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + addDialectViewModel.addDialect( + localChatRoom = addDialectUIState.localChatRoom, + nameField = nameFieldState, + countryField = countryFieldState, + languageField = languageFieldState, + onSuccess = { + // Back to the group, reloaded so the new + // dialect shows up in the list. + onNavigateToRouteAndPopUpInclusive.invoke( + ChatRoomDetailRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed Dialect") + ) + } + ) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Add dialect" + ) + Text("Add Dialect") + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).fillMaxSize() + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Add a dialect the group can translate into") + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = nameFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Title, + contentDescription = "Name of the dialect" + ) + }, + label = { + Text( + text = "Dialect Name" + ) + }, + placeholder = { + Text( + text = "eg. Sesotho" + ) + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = countryFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Public, + contentDescription = "Country of the dialect" + ) + }, + label = { + Text( + text = "Country" + ) + }, + placeholder = { + Text( + text = "eg. Lesotho" + ) + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = languageFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Translate, + contentDescription = "Language of the dialect" + ) + }, + label = { + Text( + text = "Language" + ) + }, + placeholder = { + Text( + text = "eg. st" + ) + }, + ) + } + } + } + } + AddDialectUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + 20.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = "Add Dialect", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator( + fillScreen = false + ) + + Spacer( + modifier = Modifier.weight(2f) + ) + } + } + } + + LaunchedEffect(true) { + if (initialAddDialectUIState == AddDialectUIState.Loading) { + addDialectViewModel.initiateAddDialect() + } + } +} + +@Preview +@Composable +private fun AddDialectScreenPreview() { + TorchTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + AddDialectScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + initialAddDialectUIState = AddDialectUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "", + userPublicKey = "", + subject = "Message title", + description = "See something. Say somethin", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = null + ), + ) + ), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateToRoute = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 1bce2c76..ba444544 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.LibraryBooks import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Schema +import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Unsubscribe import androidx.compose.material.icons.filled.WaterfallChart import androidx.compose.material3.ButtonDefaults @@ -60,6 +61,7 @@ import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute +import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -217,6 +219,72 @@ fun ChatRoomDetailScreen( HorizontalDivider() } + item { + // Dialects + Text( + text = "Dialects", + style = MaterialTheme.typography.labelMedium + ) + } + + if (chatRoomDetailUIState.dialects.isEmpty()) { + item { + Text("No dialects have been defined in this group.") + } + } else { + items( + items = chatRoomDetailUIState.dialects, + key = { dialect -> dialect.id } + ) { dialect -> + Card { + ListItem( + leadingContent = { + Icon( + Icons.Default.Translate, + contentDescription = "Dialect" + ) + }, + headlineContent = { + Text(text = dialect.name) + }, + supportingContent = { + Text(text = "${dialect.language} \u00b7 ${dialect.country}") + } + ) + } + } + } + + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + AddDialectRoute( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint + ) + ) + } + ) { + + Icon( + Icons.Default.Translate, + contentDescription = "Add new dialect" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text("Add Dialect") + } + } + + item { + HorizontalDivider() + } + // TODO: Add projects... item { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 9f9d612e..5dad096d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -103,6 +103,7 @@ import kotlinx.coroutines.launch import press.mantra.compose.database.repository.DatabaseMantraRepository import press.mantra.compose.ui.composable.AddArtifactScreen import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute +import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute @@ -112,6 +113,7 @@ import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRo import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.ArtifactDetailScreen import press.mantra.compose.ui.composable.AddChapterScreen +import press.mantra.compose.ui.composable.AddDialectScreen import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen import press.mantra.compose.ui.composable.ChapterDetailScreen import press.mantra.compose.ui.composable.TranslateChunkScreen @@ -814,6 +816,34 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddDialectScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + mantraRepository = databaseMantraRepository, + onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute -> + // Replace both this add screen and the stale chat room detail + // beneath it so we land on a freshly-loaded detail screen. + navController.navigate( + route = chatRoomDetailRoute + ) { + popUpTo { + inclusive = true + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectRoute.kt new file mode 100644 index 00000000..b66a8ee4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectRoute.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class AddDialectRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt new file mode 100644 index 00000000..b4a3abfb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt @@ -0,0 +1,134 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.AddDialectUIState + +class AddDialectViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialAddDialectUIState: AddDialectUIState, + val nostrRepository: NostrRepository, + val chatRepository: ChatRepository, + val mantraRepository: MantraRepository, +): ViewModel() { + + var addDialectUIState: AddDialectUIState by mutableStateOf(initialAddDialectUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateAddDialect() { + logger.d("compressed (most likely chat room): $chatRoomId") + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + addDialectUIState = if (localChatRoom == null) { + AddDialectUIState.Error("Couldn't find the chat room") + } else { + AddDialectUIState.Loaded( + localChatRoom = localChatRoom, + ) + } + } + } + + fun addDialect( + localChatRoom: LocalChatRoom, + nameField: TextFieldState, + countryField: TextFieldState, + languageField: TextFieldState, + onSuccess: (dialectId: String) -> Unit, + onFailure: () -> Unit + ) { + val name = nameField.text.toString() + val country = countryField.text.toString() + val language = languageField.text.toString() + + if (name.isBlank() || country.isBlank() || language.isBlank()) { + onFailure.invoke() + return + } + + // Guard against double submits from repeated FAB taps. + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val dialectInnerEvent = runCatching { + mantraRepository.addDialect( + localChatRoom = localChatRoom, + name = name, + country = country, + language = language, + userPublicKey = activeUserPublicKey, + ) + }.onFailure { error -> + logger.e("Failed to add dialect", error) + }.getOrNull() + + if (dialectInnerEvent != null) { + nameField.clearText() + countryField.clearText() + languageField.clearText() + + viewModelScope.launch(Dispatchers.Main) { + onSuccess.invoke(dialectInnerEvent.id) + } + } else { + viewModelScope.launch(Dispatchers.Main) { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "AddDialectViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + mantraRepository: MantraRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddDialectViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddDialectUIState = initialAddDialectUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + mantraRepository = mantraRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 89535ec0..9e1d7068 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -55,6 +55,7 @@ class ChatRoomDetailViewModel( ChatRoomDetailUIState.Loaded( localChatRoom = localChatRoom, artifacts = mantraRepository.getArtifacts(chatRoomId), + dialects = mantraRepository.getDialects(chatRoomId), ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt new file mode 100644 index 00000000..3f66e18b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt @@ -0,0 +1,15 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface AddDialectUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + ): AddDialectUIState + + data class Error( + val message: String + ): AddDialectUIState + + data object Loading: AddDialectUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index cb464925..7d710357 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -1,12 +1,14 @@ package press.mantra.compose.ui.view.state import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom sealed interface ChatRoomDetailUIState { data class Loaded( val localChatRoom: LocalChatRoom, val artifacts: List = emptyList(), + val dialects: List = emptyList(), ): ChatRoomDetailUIState data class Error(