diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 7bb08ca..b835298 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -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) diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/Conversation.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/Conversation.kt index 71c83b3..64bee4b 100644 --- a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/Conversation.kt +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/Conversation.kt @@ -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 { diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationViewModel.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationViewModel.kt index ed77983..2e19b5b 100644 --- a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationViewModel.kt +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationViewModel.kt @@ -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() val conversationData: LiveData = _conversationData fun setConversationData(phoneNumber: String?) { if (phoneNumber != null) { - this.phoneNumber = phoneNumber + this.remotePhoneNumber = phoneNumber var messageDtoList: List = 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, ) { private val _messages: MutableList = initialMessages.toMutableStateList() diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/data/FakeData.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/data/FakeData.kt index 62b2521..6a78b9c 100644 --- a/android/src/main/kotlin/xyz/magicalbits/smsremote/data/FakeData.kt +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/data/FakeData.kt @@ -35,7 +35,7 @@ val unreadMessages = initialMessages.filter { it.author != "me" } val exampleUiStateNew = ConversationScreenState( - phoneNumber = "", + remotePhoneNumber = "", initialMessages = mutableListOf() ) diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceFragment.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceFragment.kt index 297d523..3d64681 100644 --- a/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceFragment.kt +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceFragment.kt @@ -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) }, ) } diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/network/NetworkClient.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/network/NetworkClient.kt index e6f9460..4b10f8d 100644 --- a/android/src/main/kotlin/xyz/magicalbits/smsremote/network/NetworkClient.kt +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/network/NetworkClient.kt @@ -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 { // 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 { + // 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()) + } } diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/sim/Sim.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/sim/Sim.kt new file mode 100644 index 0000000..73627c3 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/sim/Sim.kt @@ -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)) +} + diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/sim/SimFragment.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/sim/SimFragment.kt new file mode 100644 index 0000000..525b10e --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/sim/SimFragment.kt @@ -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(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(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 + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/sim/SimViewModel.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/sim/SimViewModel.kt new file mode 100644 index 0000000..be219c3 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/sim/SimViewModel.kt @@ -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() + val simData: LiveData = _simConversations + + fun setSimData(phoneNumber: String?) { + if (phoneNumber != null) { + simPhoneNumber = phoneNumber + + var conversationDtoList: List = 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, +) + +@Immutable +data class ConversationRowState( + val phoneNumber: String, + val lastMessage: String, + val messageTimestamp: Int, +) diff --git a/android/src/main/res/navigation/mobile_navigation.xml b/android/src/main/res/navigation/mobile_navigation.xml index eac44a3..71f0c68 100644 --- a/android/src/main/res/navigation/mobile_navigation.xml +++ b/android/src/main/res/navigation/mobile_navigation.xml @@ -50,7 +50,20 @@ android:name="type" app:argType="string" /> + + + + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index d4a0159..722cef9 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -53,6 +53,9 @@ There was an error loading the device + + There was an error loading the SIM + Emoji selector diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc907f0..2b8b44d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }