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.auth)
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio) implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
debugImplementation(libs.androidx.compose.ui.test.manifest) 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.FunctionalityNotAvailablePopup
import xyz.magicalbits.smsremote.R import xyz.magicalbits.smsremote.R
import xyz.magicalbits.smsremote.components.JetchatAppBar import xyz.magicalbits.smsremote.components.JetchatAppBar
import xyz.magicalbits.smsremote.data.exampleUiState
import xyz.magicalbits.smsremote.theme.JetchatTheme import xyz.magicalbits.smsremote.theme.JetchatTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import xyz.magicalbits.smsremote.data.exampleUiStateNew import xyz.magicalbits.smsremote.data.exampleUiStateNew
import xyz.magicalbits.smsremote.network.NetworkClient
/** /**
* Entry point for a conversation screen. * Entry point for a conversation screen.
@@ -168,9 +168,8 @@ fun ConversationContent(
Scaffold( Scaffold(
topBar = { topBar = {
ChannelNameBar( ChannelNameBar(
channelName = uiState.phoneNumber, channelName = uiState.remotePhoneNumber,
// TODO remove? channelMembers = 2, // TODO remove?
channelMembers = 2,
onNavIconPressed = onNavIconPressed, onNavIconPressed = onNavIconPressed,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
@@ -205,6 +204,11 @@ fun ConversationContent(
uiState.addMessage( uiState.addMessage(
Message(authorMe, content, timeNow), Message(authorMe, content, timeNow),
) )
scope.launch {
val networkClient = NetworkClient()
// FIXME
// networkClient.sendSmsMessage(content, uiState.localPhoneNumber, uiState.remotePhoneNumber)
}
}, },
resetScroll = { resetScroll = {
scope.launch { scope.launch {
@@ -9,13 +9,13 @@ import kotlinx.coroutines.launch
import xyz.magicalbits.smsremote.network.NetworkClient import xyz.magicalbits.smsremote.network.NetworkClient
class ConversationViewModel : ViewModel() { class ConversationViewModel : ViewModel() {
private var phoneNumber: String = "" private var remotePhoneNumber: String = ""
private val _conversationData = MutableLiveData<ConversationScreenState>() private val _conversationData = MutableLiveData<ConversationScreenState>()
val conversationData: LiveData<ConversationScreenState> = _conversationData val conversationData: LiveData<ConversationScreenState> = _conversationData
fun setConversationData(phoneNumber: String?) { fun setConversationData(phoneNumber: String?) {
if (phoneNumber != null) { if (phoneNumber != null) {
this.phoneNumber = phoneNumber this.remotePhoneNumber = phoneNumber
var messageDtoList: List<NetworkClient.SmsMessageDto> = listOf() var messageDtoList: List<NetworkClient.SmsMessageDto> = listOf()
viewModelScope.launch { viewModelScope.launch {
@@ -24,7 +24,7 @@ class ConversationViewModel : ViewModel() {
}.invokeOnCompletion { }.invokeOnCompletion {
_conversationData.value = _conversationData.value =
ConversationScreenState( ConversationScreenState(
phoneNumber = this.phoneNumber, remotePhoneNumber = this.remotePhoneNumber,
initialMessages = messageDtoList.map { initialMessages = messageDtoList.map {
Message( Message(
if (it.msg_type == "INCOMING") { if (it.msg_type == "INCOMING") {
@@ -46,7 +46,7 @@ class ConversationViewModel : ViewModel() {
} }
data class ConversationScreenState( data class ConversationScreenState(
val phoneNumber: String, val remotePhoneNumber: String,
val initialMessages: List<Message>, val initialMessages: List<Message>,
) { ) {
private val _messages: MutableList<Message> = initialMessages.toMutableStateList() private val _messages: MutableList<Message> = initialMessages.toMutableStateList()
@@ -35,7 +35,7 @@ val unreadMessages = initialMessages.filter { it.author != "me" }
val exampleUiStateNew = val exampleUiStateNew =
ConversationScreenState( ConversationScreenState(
phoneNumber = "", remotePhoneNumber = "",
initialMessages = mutableListOf() initialMessages = mutableListOf()
) )
@@ -75,7 +75,7 @@ class DeviceFragment : Fragment() {
onPhoneNumberClicked = { onPhoneNumberClicked = {
val args = Bundle(1) val args = Bundle(1)
args.putString("phoneNumber", it) 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.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer 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.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -20,6 +26,9 @@ class NetworkClient {
// TODO ask user for creds, call login endpoint, store tokens in local encrypted storage // TODO ask user for creds, call login endpoint, store tokens in local encrypted storage
private val networkClient = HttpClient(CIO) { private val networkClient = HttpClient(CIO) {
install(ContentNegotiation) {
json()
}
install(Auth) { install(Auth) {
bearer { bearer {
// TODO configure refresh token so it refreshes the access token // TODO configure refresh token so it refreshes the access token
@@ -43,6 +52,12 @@ class NetworkClient {
@Serializable @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) 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 // GET /api/v1/devices
suspend fun getDevices(): List<DeviceDto> { suspend fun getDevices(): List<DeviceDto> {
// TODO handle non-200 status codes // 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() val data = networkClient.get("$apiBaseUrl/api/v1/sms-messages?local_phone_number=$encodedPhoneNumber").bodyAsText()
return Json.decodeFromString(data) 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" android:name="type"
app:argType="string" /> app:argType="string" />
<action <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" app:destination="@id/nav_conversation"
/> />
</fragment> </fragment>
+3
View File
@@ -53,6 +53,9 @@
<!-- Device --> <!-- Device -->
<string name="device_error">There was an error loading the device</string> <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 --> <!-- Accessibility descriptions -->
<string name="emoji_selector_desc">Emoji selector</string> <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-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-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-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" } okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
okhttp3 = { module = "com.squareup.okhttp3:okhttp", 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" } play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" }