wip: put SimFragment between DeviceFragment and ConversationFragment

This commit is contained in:
2026-06-09 23:16:59 +02:00
parent 43ff936b24
commit defaa15e93
12 changed files with 340 additions and 11 deletions
+2
View File
@@ -121,6 +121,8 @@ dependencies {
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
debugImplementation(libs.androidx.compose.ui.test.manifest)
@@ -89,10 +89,10 @@ import androidx.compose.ui.unit.dp
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
import xyz.magicalbits.smsremote.R
import xyz.magicalbits.smsremote.components.JetchatAppBar
import xyz.magicalbits.smsremote.data.exampleUiState
import xyz.magicalbits.smsremote.theme.JetchatTheme
import kotlinx.coroutines.launch
import xyz.magicalbits.smsremote.data.exampleUiStateNew
import xyz.magicalbits.smsremote.network.NetworkClient
/**
* Entry point for a conversation screen.
@@ -168,9 +168,8 @@ fun ConversationContent(
Scaffold(
topBar = {
ChannelNameBar(
channelName = uiState.phoneNumber,
// TODO remove?
channelMembers = 2,
channelName = uiState.remotePhoneNumber,
channelMembers = 2, // TODO remove?
onNavIconPressed = onNavIconPressed,
scrollBehavior = scrollBehavior,
)
@@ -205,6 +204,11 @@ fun ConversationContent(
uiState.addMessage(
Message(authorMe, content, timeNow),
)
scope.launch {
val networkClient = NetworkClient()
// FIXME
// networkClient.sendSmsMessage(content, uiState.localPhoneNumber, uiState.remotePhoneNumber)
}
},
resetScroll = {
scope.launch {
@@ -9,13 +9,13 @@ import kotlinx.coroutines.launch
import xyz.magicalbits.smsremote.network.NetworkClient
class ConversationViewModel : ViewModel() {
private var phoneNumber: String = ""
private var remotePhoneNumber: String = ""
private val _conversationData = MutableLiveData<ConversationScreenState>()
val conversationData: LiveData<ConversationScreenState> = _conversationData
fun setConversationData(phoneNumber: String?) {
if (phoneNumber != null) {
this.phoneNumber = phoneNumber
this.remotePhoneNumber = phoneNumber
var messageDtoList: List<NetworkClient.SmsMessageDto> = listOf()
viewModelScope.launch {
@@ -24,7 +24,7 @@ class ConversationViewModel : ViewModel() {
}.invokeOnCompletion {
_conversationData.value =
ConversationScreenState(
phoneNumber = this.phoneNumber,
remotePhoneNumber = this.remotePhoneNumber,
initialMessages = messageDtoList.map {
Message(
if (it.msg_type == "INCOMING") {
@@ -46,7 +46,7 @@ class ConversationViewModel : ViewModel() {
}
data class ConversationScreenState(
val phoneNumber: String,
val remotePhoneNumber: String,
val initialMessages: List<Message>,
) {
private val _messages: MutableList<Message> = initialMessages.toMutableStateList()
@@ -35,7 +35,7 @@ val unreadMessages = initialMessages.filter { it.author != "me" }
val exampleUiStateNew =
ConversationScreenState(
phoneNumber = "",
remotePhoneNumber = "",
initialMessages = mutableListOf()
)
@@ -75,7 +75,7 @@ class DeviceFragment : Fragment() {
onPhoneNumberClicked = {
val args = Bundle(1)
args.putString("phoneNumber", it)
navController.navigate(R.id.action_device_to_conversation, args)
navController.navigate(R.id.action_device_to_sim, args)
},
)
}
@@ -5,8 +5,14 @@ import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@@ -20,6 +26,9 @@ class NetworkClient {
// TODO ask user for creds, call login endpoint, store tokens in local encrypted storage
private val networkClient = HttpClient(CIO) {
install(ContentNegotiation) {
json()
}
install(Auth) {
bearer {
// TODO configure refresh token so it refreshes the access token
@@ -43,6 +52,12 @@ class NetworkClient {
@Serializable
data class SmsMessageDto(val content: String, val ts_received: Int, val ts_sent: Int, val msg_type: String, val local_phone_number: String, val remote_phone_number: String)
@Serializable
data class OutgoingMessage(val content: String, val local_phone_number: String, val remote_phone_number: String)
@Serializable
data class ConversationPreviewDto(val remote_phone_number: String, val last_message_content: String, val message_timestamp: Int)
// GET /api/v1/devices
suspend fun getDevices(): List<DeviceDto> {
// TODO handle non-200 status codes
@@ -70,4 +85,24 @@ class NetworkClient {
val data = networkClient.get("$apiBaseUrl/api/v1/sms-messages?local_phone_number=$encodedPhoneNumber").bodyAsText()
return Json.decodeFromString(data)
}
// POST /api/v1/send-message
suspend fun sendSmsMessage(content: String, localPhoneNumber: String, remotePhoneNumber: String) {
println("sending SMS message: content=$content, lPN=$localPhoneNumber, rPN=$remotePhoneNumber")
val response = networkClient.post("$apiBaseUrl/api/v1/send-message") {
contentType(ContentType.Application.Json)
setBody(OutgoingMessage(content, localPhoneNumber, remotePhoneNumber))
}
println("sending SMS message: status code ${response.status}")
}
// GET /api/v1/conversation-previews
suspend fun getConversationPreviews(simPhoneNumber: String): List<ConversationPreviewDto> {
// TODO extract encoder to a function here or to a utility class
val encodedPhoneNumber = withContext(Dispatchers.IO) {
URLEncoder.encode(simPhoneNumber, "UTF-8")
}
val response = networkClient.get("$apiBaseUrl/api/v1/conversation-previews?local_phone_number=$encodedPhoneNumber")
return Json.decodeFromString(response.bodyAsText())
}
}
@@ -0,0 +1,119 @@
package xyz.magicalbits.smsremote.sim
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import xyz.magicalbits.smsremote.R
import xyz.magicalbits.smsremote.components.baselineHeight
import xyz.magicalbits.smsremote.theme.JetchatTheme
@Composable
fun SimScreen(
simData: SimScreenState,
nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
onConversationClicked: (String) -> Unit = { },
) {
val scrollState = rememberScrollState()
Box(
modifier = Modifier.fillMaxSize().nestedScroll(nestedScrollInteropConnection)
) {
Surface {
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(horizontal = 16.dp)) {
Name(simData, Modifier.baselineHeight(32.dp))
Spacer(modifier = Modifier.height(8.dp))
simData.conversations.forEach {
ConversationPreview(it, onConversationClicked)
}
}
}
}
}
@Composable
private fun Name(
simData: SimScreenState,
modifier: Modifier
) {
Text(
text = simData.phoneNumber,
modifier = modifier,
style = MaterialTheme.typography.headlineSmall
)
}
@Composable
private fun ConversationPreview(
conversation: ConversationRowState,
onConversationClicked: (String) -> Unit,
) {
Column(modifier = Modifier
.clickable(
onClick = {
onConversationClicked(conversation.phoneNumber)
}
)
) {
HorizontalDivider()
Row(
modifier = Modifier
.wrapContentHeight()
.padding(start = 16.dp, end = 16.dp)
) {
Text(text = conversation.phoneNumber)
Spacer(modifier = Modifier.weight(1f))
Text(text = conversation.messageTimestamp.toString())
}
Row(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp)
) {
Text(text = conversation.lastMessage)
}
}
}
@Preview
@Composable
fun SimScreenPreview() {
JetchatTheme {
SimScreen(
simData = SimScreenState(
phoneNumber = "+420123456789",
conversations = listOf(
ConversationRowState("+15558880000", "last msg", 12345),
ConversationRowState("+15558880000", "last msg", 12345),
ConversationRowState("+15558880000", "last msg", 12345),
)
)
)
}
}
@Composable
fun SimError() {
Text(stringResource(R.string.sim_error))
}
@@ -0,0 +1,95 @@
package xyz.magicalbits.smsremote.sim
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.findNavController
import xyz.magicalbits.smsremote.MainViewModel
import xyz.magicalbits.smsremote.R
import xyz.magicalbits.smsremote.components.JetchatAppBar
import xyz.magicalbits.smsremote.theme.JetchatTheme
class SimFragment : Fragment() {
private val viewModel: SimViewModel by viewModels()
private val activityViewModel: MainViewModel by activityViewModels()
override fun onAttach(context: Context) {
super.onAttach(context)
// Consider using safe args plugin
val phoneNumber = arguments?.getString("phoneNumber")
viewModel.setSimData(phoneNumber)
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false)
rootView.findViewById<ComposeView>(R.id.toolbar_compose_view).apply {
setContent {
JetchatTheme {
JetchatAppBar(
// Reset the minimum bounds that are passed to the root of a compose tree
modifier = Modifier.wrapContentSize(),
onNavIconPressed = { activityViewModel.openDrawer() },
title = { },
)
}
}
}
rootView.findViewById<ComposeView>(R.id.profile_compose_view).apply {
setContent {
val simData by viewModel.simData.observeAsState()
val nestedScrollInteropConnection = rememberNestedScrollInteropConnection()
JetchatTheme {
// TODO flip if condition after integrating API call
if (simData != null) {
SimError()
println("calling sim error")
} else {
val navController: NavController = rootView.findNavController()
SimScreen(
// simData = simData!!,
// TODO remove fake data after integrating API call
simData = SimScreenState(
phoneNumber = "+420123456789",
conversations = listOf(
ConversationRowState("+15558880000", "last msg", 12345),
ConversationRowState("+15558880111", "last msg", 12345),
ConversationRowState("+15558880333", "last msg", 12345),
)
),
nestedScrollInteropConnection = nestedScrollInteropConnection,
onConversationClicked = {
val args = Bundle(1)
args.putString("phoneNumber", it)
navController.navigate(R.id.action_sim_to_conversation, args)
},
)
}
}
}
}
return rootView
}
}
@@ -0,0 +1,56 @@
package xyz.magicalbits.smsremote.sim
import androidx.compose.runtime.Immutable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import xyz.magicalbits.smsremote.network.NetworkClient
class SimViewModel : ViewModel() {
private var simPhoneNumber: String = ""
private val _simConversations = MutableLiveData<SimScreenState>()
val simData: LiveData<SimScreenState> = _simConversations
fun setSimData(phoneNumber: String?) {
if (phoneNumber != null) {
simPhoneNumber = phoneNumber
var conversationDtoList: List<NetworkClient.ConversationPreviewDto> = listOf()
// TODO uncomment after adding the corresponding API endpoint
// viewModelScope.launch {
// val networkClient = NetworkClient()
// conversationDtoList = networkClient.getConversationPreviews(simPhoneNumber)
// println("conv previews: $conversationDtoList")
// }.invokeOnCompletion {
// _simConversations.value =
// SimScreenState(
// phoneNumber = simPhoneNumber,
// conversations = conversationDtoList.map { convertFromDto(it) }.toList()
// )
// }
}
}
}
fun convertFromDto(conversation: NetworkClient.ConversationPreviewDto): ConversationRowState {
return ConversationRowState(
phoneNumber = conversation.remote_phone_number,
lastMessage = conversation.last_message_content,
messageTimestamp = conversation.message_timestamp,
)
}
@Immutable
data class SimScreenState(
val phoneNumber: String,
val conversations: List<ConversationRowState>,
)
@Immutable
data class ConversationRowState(
val phoneNumber: String,
val lastMessage: String,
val messageTimestamp: Int,
)
@@ -50,7 +50,20 @@
android:name="type"
app:argType="string" />
<action
android:id="@+id/action_device_to_conversation"
android:id="@+id/action_device_to_sim"
app:destination="@id/nav_sim"
/>
</fragment>
<fragment
android:id="@+id/nav_sim"
android:name="xyz.magicalbits.smsremote.sim.SimFragment"
android:label="Sim">
<argument
android:name="phoneNumber"
app:argType="string" />
<action
android:id="@+id/action_sim_to_conversation"
app:destination="@id/nav_conversation"
/>
</fragment>
+3
View File
@@ -53,6 +53,9 @@
<!-- Device -->
<string name="device_error">There was an error loading the device</string>
<!-- Sim -->
<string name="sim_error">There was an error loading the SIM</string>
<!-- Accessibility descriptions -->
<string name="emoji_selector_desc">Emoji selector</string>
+2
View File
@@ -151,6 +151,8 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor-client"}
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor-client"}
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor-client"}
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor-client"}
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-client"}
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" }