diff --git a/.gitignore b/.gitignore
index a217e90..9552451 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,4 +14,8 @@ log/
*.db
# Gradle
+.gradle/
local.properties
+
+# IDEA
+.idea/
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..43414fb
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.compose)
+}
+
+android {
+ compileSdk = libs.versions.compileSdk.get().toInt()
+ namespace = "xyz.magicalbits.smsremote"
+
+ defaultConfig {
+ applicationId = "xyz.magicalbits.smsremote"
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.targetSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ vectorDrawables.useSupportLibrary = true
+ }
+
+ signingConfigs {
+ // Important: change the keystore for a production deployment
+ val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore")
+ val localKeystore = rootProject.file("debug_2.keystore")
+ val hasKeyInfo = userKeystore.exists()
+ create("release") {
+ storeFile = if (hasKeyInfo) userKeystore else localKeystore
+ storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password")
+ keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias")
+ keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password")
+ }
+ }
+
+ buildTypes {
+ getByName("debug") {
+ }
+
+ getByName("release") {
+ isMinifyEnabled = true
+ signingConfig = signingConfigs.getByName("release")
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+
+ kotlin {
+ compilerOptions {
+ jvmTarget = JvmTarget.fromTarget("17")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ buildFeatures {
+ compose = true
+ viewBinding = true
+ }
+
+ packaging.resources {
+ // Multiple dependency bring these files in. Exclude them to enable
+ // our test APK to build (has no effect on our AARs)
+ excludes += "/META-INF/AL2.0"
+ excludes += "/META-INF/LGPL2.1"
+ }
+}
+
+dependencies {
+ val composeBom = platform(libs.androidx.compose.bom)
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ implementation(libs.androidx.glance.appwidget)
+ implementation(libs.androidx.glance.material3)
+ implementation(libs.kotlin.stdlib)
+ implementation(libs.kotlinx.coroutines.android)
+
+ implementation(libs.androidx.activity.compose)
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.compose.runtime.livedata)
+ implementation(libs.androidx.lifecycle.viewModelCompose)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.navigation.fragment)
+ implementation(libs.androidx.navigation.ui.ktx)
+
+ implementation(libs.androidx.compose.foundation.layout)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+ implementation(libs.androidx.compose.ui.util)
+ implementation(libs.androidx.compose.ui.viewbinding)
+ implementation(libs.androidx.compose.ui.googlefonts)
+
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
+
+ androidTestImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.test.core)
+ androidTestImplementation(libs.androidx.test.runner)
+ androidTestImplementation(libs.androidx.test.espresso.core)
+ androidTestImplementation(libs.androidx.test.rules)
+ androidTestImplementation(libs.androidx.test.ext.junit)
+ androidTestImplementation(libs.kotlinx.coroutines.test)
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+}
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..676ce5b
--- /dev/null
+++ b/android/src/main/AndroidManifest.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/MainViewModel.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/MainViewModel.kt
new file mode 100644
index 0000000..45e0128
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/MainViewModel.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Used to communicate between screens.
+ */
+class MainViewModel : ViewModel() {
+ private val _drawerShouldBeOpened = MutableStateFlow(false)
+ val drawerShouldBeOpened = _drawerShouldBeOpened.asStateFlow()
+
+ fun openDrawer() {
+ _drawerShouldBeOpened.value = true
+ }
+
+ fun resetOpenDrawerAction() {
+ _drawerShouldBeOpened.value = false
+ }
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/NavActivity.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/NavActivity.kt
new file mode 100644
index 0000000..7afd641
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/NavActivity.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote
+
+import android.os.Bundle
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.material3.DrawerValue.Closed
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.rememberDrawerState
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.viewinterop.AndroidViewBinding
+import androidx.core.os.bundleOf
+import androidx.core.view.ViewCompat
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
+import kotlinx.coroutines.launch
+import xyz.magicalbits.smsremote.components.JetchatDrawer
+import xyz.magicalbits.smsremote.databinding.ContentMainBinding
+
+/**
+ * Main activity for the app.
+ */
+class NavActivity : AppCompatActivity() {
+ private val viewModel: MainViewModel by viewModels()
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+ ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> insets }
+
+ setContentView(
+ ComposeView(this).apply {
+ consumeWindowInsets = false
+ setContent {
+ val drawerState = rememberDrawerState(initialValue = Closed)
+ val drawerOpen by viewModel.drawerShouldBeOpened
+ .collectAsStateWithLifecycle()
+
+ var selectedMenu by remember { mutableStateOf("Samsung A14") }
+ if (drawerOpen) {
+ // Open drawer and reset state in VM.
+ LaunchedEffect(Unit) {
+ // wrap in try-finally to handle interruption whiles opening drawer
+ try {
+ drawerState.open()
+ } finally {
+ viewModel.resetOpenDrawerAction()
+ }
+ }
+ }
+
+ val scope = rememberCoroutineScope()
+
+ JetchatDrawer(
+ drawerState = drawerState,
+ selectedMenu = selectedMenu,
+ onChatClicked = {
+ findNavController().popBackStack(R.id.nav_device, false)
+ val args = Bundle(1)
+ args.putString("deviceName", it)
+ findNavController().navigate(R.id.nav_device, args)
+ scope.launch {
+ drawerState.close()
+ }
+ selectedMenu = it
+ },
+ ) {
+ AndroidViewBinding(ContentMainBinding::inflate)
+ }
+ }
+ },
+ )
+ }
+
+ override fun onSupportNavigateUp(): Boolean = findNavController().navigateUp() || super.onSupportNavigateUp()
+
+ /**
+ * See https://issuetracker.google.com/142847973
+ */
+ private fun findNavController(): NavController {
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
+ return navHostFragment.navController
+ }
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/UiExtras.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/UiExtras.kt
new file mode 100644
index 0000000..8b85341
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/UiExtras.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+
+@Composable
+fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ text = {
+ Text(
+ text = "Functionality not available \uD83D\uDE48",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text(text = "CLOSE")
+ }
+ },
+ )
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/components/AnimatingFabContent.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/AnimatingFabContent.kt
new file mode 100644
index 0000000..6de824d
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/AnimatingFabContent.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.components
+
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.util.lerp
+import kotlin.math.roundToInt
+
+/**
+ * A layout that shows an icon and a text element used as the content for a FAB that extends with
+ * an animation.
+ */
+@Composable
+fun AnimatingFabContent(
+ icon: @Composable () -> Unit,
+ text: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ extended: Boolean = true,
+) {
+ val currentState = if (extended) ExpandableFabStates.Extended else ExpandableFabStates.Collapsed
+ val transition = updateTransition(currentState, "fab_transition")
+
+ val textOpacity by transition.animateFloat(
+ transitionSpec = {
+ if (targetState == ExpandableFabStates.Collapsed) {
+ tween(
+ easing = LinearEasing,
+ durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames
+ )
+ } else {
+ tween(
+ easing = LinearEasing,
+ delayMillis = (transitionDuration / 3f).roundToInt(), // 4 / 12 frames
+ durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames
+ )
+ }
+ },
+ label = "fab_text_opacity",
+ ) { state ->
+ if (state == ExpandableFabStates.Collapsed) {
+ 0f
+ } else {
+ 1f
+ }
+ }
+ val fabWidthFactor by transition.animateFloat(
+ transitionSpec = {
+ if (targetState == ExpandableFabStates.Collapsed) {
+ tween(
+ easing = FastOutSlowInEasing,
+ durationMillis = transitionDuration,
+ )
+ } else {
+ tween(
+ easing = FastOutSlowInEasing,
+ durationMillis = transitionDuration,
+ )
+ }
+ },
+ label = "fab_width_factor",
+ ) { state ->
+ if (state == ExpandableFabStates.Collapsed) {
+ 0f
+ } else {
+ 1f
+ }
+ }
+ // Deferring reads using lambdas instead of Floats here can improve performance,
+ // preventing recompositions.
+ IconAndTextRow(
+ icon,
+ text,
+ { textOpacity },
+ { fabWidthFactor },
+ modifier = modifier,
+ )
+}
+
+@Composable
+private fun IconAndTextRow(
+ icon: @Composable () -> Unit,
+ text: @Composable () -> Unit,
+ opacityProgress: () -> Float, // Lambdas instead of Floats, to defer read
+ widthProgress: () -> Float,
+ modifier: Modifier,
+) {
+ Layout(
+ modifier = modifier,
+ content = {
+ icon()
+ Box(modifier = Modifier.graphicsLayer { alpha = opacityProgress() }) {
+ text()
+ }
+ },
+ ) { measurables, constraints ->
+
+ val iconPlaceable = measurables[0].measure(constraints)
+ val textPlaceable = measurables[1].measure(constraints)
+
+ val height = constraints.maxHeight
+
+ // FAB has an aspect ratio of 1 so the initial width is the height
+ val initialWidth = height.toFloat()
+
+ // Use it to get the padding
+ val iconPadding = (initialWidth - iconPlaceable.width) / 2f
+
+ // The full width will be : padding + icon + padding + text + padding
+ val expandedWidth = iconPlaceable.width + textPlaceable.width + iconPadding * 3
+
+ // Apply the animation factor to go from initialWidth to fullWidth
+ val width = lerp(initialWidth, expandedWidth, widthProgress())
+
+ layout(width.roundToInt(), height) {
+ iconPlaceable.place(
+ iconPadding.roundToInt(),
+ constraints.maxHeight / 2 - iconPlaceable.height / 2,
+ )
+ textPlaceable.place(
+ (iconPlaceable.width + iconPadding * 2).roundToInt(),
+ constraints.maxHeight / 2 - textPlaceable.height / 2,
+ )
+ }
+ }
+}
+
+private enum class ExpandableFabStates { Collapsed, Extended }
+
+private const val transitionDuration = 200
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/components/BaseLineHeightModifier.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/BaseLineHeightModifier.kt
new file mode 100644
index 0000000..3b19c5a
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/BaseLineHeightModifier.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.components
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.FirstBaseline
+import androidx.compose.ui.layout.LastBaseline
+import androidx.compose.ui.layout.LayoutModifier
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+
+/**
+ * Applied to a Text, it sets the distance between the top and the first baseline. It
+ * also makes the bottom of the element coincide with the last baseline of the text.
+ *
+ * _______________
+ * | | ↑
+ * | | | heightFromBaseline
+ * |Hello, World!| ↓
+ * ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+ *
+ * This modifier can be used to distribute multiple text elements using a certain distance between
+ * baselines.
+ */
+data class BaselineHeightModifier(
+ val heightFromBaseline: Dp,
+) : LayoutModifier {
+ override fun MeasureScope.measure(
+ measurable: Measurable,
+ constraints: Constraints,
+ ): MeasureResult {
+ val textPlaceable = measurable.measure(constraints)
+ val firstBaseline = textPlaceable[FirstBaseline]
+ val lastBaseline = textPlaceable[LastBaseline]
+
+ val height = heightFromBaseline.roundToPx() + lastBaseline - firstBaseline
+ return layout(constraints.maxWidth, height) {
+ val topY = heightFromBaseline.roundToPx() - firstBaseline
+ textPlaceable.place(0, topY)
+ }
+ }
+}
+
+fun Modifier.baselineHeight(heightFromBaseline: Dp): Modifier = this.then(BaselineHeightModifier(heightFromBaseline))
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatAppBar.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatAppBar.kt
new file mode 100644
index 0000000..ffd74d0
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatAppBar.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package xyz.magicalbits.smsremote.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+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.theme.JetchatTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun JetchatAppBar(
+ modifier: Modifier = Modifier,
+ scrollBehavior: TopAppBarScrollBehavior? = null,
+ onNavIconPressed: () -> Unit = { },
+ title: @Composable () -> Unit,
+ actions: @Composable RowScope.() -> Unit = {},
+) {
+ CenterAlignedTopAppBar(
+ modifier = modifier,
+ actions = actions,
+ title = title,
+ scrollBehavior = scrollBehavior,
+ navigationIcon = {
+ JetchatIcon(
+ contentDescription = stringResource(id = R.string.navigation_drawer_open),
+ modifier = Modifier
+ .size(64.dp)
+ .clickable(onClick = onNavIconPressed)
+ .padding(16.dp),
+ )
+ },
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview
+@Composable
+fun JetchatAppBarPreview() {
+ JetchatTheme {
+ JetchatAppBar(title = { Text("Preview!") })
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview
+@Composable
+fun JetchatAppBarPreviewDark() {
+ JetchatTheme(isDarkTheme = true) {
+ JetchatAppBar(title = { Text("Preview!") })
+ }
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatDrawer.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatDrawer.kt
new file mode 100644
index 0000000..d46e26a
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatDrawer.kt
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.components
+
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Context
+import android.os.Build
+import androidx.annotation.ChecksSdkIntAtLeast
+import androidx.annotation.DrawableRes
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+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.WindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.windowInsetsTopHeight
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterStart
+import androidx.compose.ui.Alignment.Companion.CenterVertically
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+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.theme.JetchatTheme
+import xyz.magicalbits.smsremote.widget.WidgetReceiver
+
+@Composable
+fun JetchatDrawerContent(onChatClicked: (String) -> Unit, selectedMenu: String = "iPhone XYZ") {
+ // Use windowInsetsTopHeight() to add a spacer which pushes the drawer content
+ // below the status bar (y-axis)
+ Column {
+ Spacer(Modifier.windowInsetsTopHeight(WindowInsets.statusBars))
+ DrawerHeader()
+ DividerItem()
+ DrawerItemHeader("Devices")
+ DeviceItem("Samsung A14", selectedMenu == "Samsung A14") {
+ onChatClicked("Samsung A14")
+ }
+ DeviceItem("iPhone XYZ", selectedMenu == "iPhone XYZ") {
+ onChatClicked("iPhone XYZ")
+ }
+// DividerItem(modifier = Modifier.padding(horizontal = 28.dp))
+ if (widgetAddingIsSupported(LocalContext.current)) {
+ DividerItem(modifier = Modifier.padding(horizontal = 28.dp))
+ DrawerItemHeader("Settings")
+ WidgetDiscoverability()
+ }
+ }
+}
+
+@Composable
+private fun DrawerHeader() {
+ Row(modifier = Modifier.padding(16.dp), verticalAlignment = CenterVertically) {
+ JetchatIcon(
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ Image(
+ painter = painterResource(id = R.drawable.jetchat_logo),
+ contentDescription = null,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+}
+
+@Composable
+private fun DrawerItemHeader(text: String) {
+ Box(
+ modifier = Modifier
+ .heightIn(min = 52.dp)
+ .padding(horizontal = 28.dp),
+ contentAlignment = CenterStart,
+ ) {
+ Text(
+ text,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Composable
+private fun DeviceItem(text: String, selected: Boolean, onChatClicked: () -> Unit) {
+ val background = if (selected) {
+ Modifier.background(MaterialTheme.colorScheme.primaryContainer)
+ } else {
+ Modifier
+ }
+ Row(
+ modifier = Modifier
+ .height(56.dp)
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp)
+ .clip(CircleShape)
+ .then(background)
+ .clickable(onClick = onChatClicked),
+ verticalAlignment = CenterVertically,
+ ) {
+ val iconTint = if (selected) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ Icon(
+ painter = painterResource(id = R.drawable.ic_jetchat),
+ tint = iconTint,
+ modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 16.dp),
+ contentDescription = null,
+ )
+ Text(
+ text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (selected) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ modifier = Modifier.padding(start = 12.dp),
+ )
+ }
+}
+
+@Composable
+private fun ProfileItem(text: String, @DrawableRes profilePic: Int?, selected: Boolean = false, onProfileClicked: () -> Unit) {
+ val background = if (selected) {
+ Modifier.background(MaterialTheme.colorScheme.primaryContainer)
+ } else {
+ Modifier
+ }
+ Row(
+ modifier = Modifier
+ .height(56.dp)
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp)
+ .clip(CircleShape)
+ .then(background)
+ .clickable(onClick = onProfileClicked),
+ verticalAlignment = CenterVertically,
+ ) {
+ val paddingSizeModifier = Modifier
+ .padding(start = 16.dp, top = 16.dp, bottom = 16.dp)
+ .size(24.dp)
+ if (profilePic != null) {
+ Image(
+ painter = painterResource(id = profilePic),
+ modifier = paddingSizeModifier.then(Modifier.clip(CircleShape)),
+ contentScale = ContentScale.Crop,
+ contentDescription = null,
+ )
+ } else {
+ Spacer(modifier = paddingSizeModifier)
+ }
+ Text(
+ text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(start = 12.dp),
+ )
+ }
+}
+
+@Composable
+fun DividerItem(modifier: Modifier = Modifier) {
+ HorizontalDivider(
+ modifier = modifier,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
+ )
+}
+
+@Composable
+@Preview
+fun DrawerPreview() {
+ JetchatTheme {
+ Surface {
+ Column {
+ JetchatDrawerContent({})
+ }
+ }
+ }
+}
+
+@Composable
+@Preview
+fun DrawerPreviewDark() {
+ JetchatTheme(isDarkTheme = true) {
+ Surface {
+ Column {
+ JetchatDrawerContent({})
+ }
+ }
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+@Composable
+private fun WidgetDiscoverability() {
+ val context = LocalContext.current
+ Row(
+ modifier = Modifier
+ .height(56.dp)
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp)
+ .clip(CircleShape)
+ .clickable(onClick = {
+ addWidgetToHomeScreen(context)
+ }),
+ verticalAlignment = CenterVertically,
+ ) {
+ Text(
+ stringResource(id = R.string.add_widget_to_home_page),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(start = 12.dp),
+ )
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+private fun addWidgetToHomeScreen(context: Context) {
+ val appWidgetManager = AppWidgetManager.getInstance(context)
+ val myProvider = ComponentName(context, WidgetReceiver::class.java)
+ if (widgetAddingIsSupported(context)) {
+ appWidgetManager.requestPinAppWidget(myProvider, null, null)
+ }
+}
+
+@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
+private fun widgetAddingIsSupported(context: Context): Boolean {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
+ AppWidgetManager.getInstance(context).isRequestPinAppWidgetSupported
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatIcon.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatIcon.kt
new file mode 100644
index 0000000..f874467
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatIcon.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import xyz.magicalbits.smsremote.R
+
+@Composable
+fun JetchatIcon(contentDescription: String?, modifier: Modifier = Modifier) {
+ val semantics = if (contentDescription != null) {
+ Modifier.semantics {
+ this.contentDescription = contentDescription
+ this.role = Role.Image
+ }
+ } else {
+ Modifier
+ }
+ Box(modifier = modifier.then(semantics)) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_jetchat_back),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primaryContainer,
+ )
+ Icon(
+ painter = painterResource(id = R.drawable.ic_jetchat_front),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatScaffold.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatScaffold.kt
new file mode 100644
index 0000000..257ab78
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatScaffold.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.components
+
+import androidx.compose.material3.DrawerState
+import androidx.compose.material3.DrawerValue.Closed
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalDrawerSheet
+import androidx.compose.material3.ModalNavigationDrawer
+import androidx.compose.material3.rememberDrawerState
+import androidx.compose.runtime.Composable
+import xyz.magicalbits.smsremote.theme.JetchatTheme
+
+@Composable
+fun JetchatDrawer(
+ drawerState: DrawerState = rememberDrawerState(initialValue = Closed),
+ selectedMenu: String,
+ onChatClicked: (String) -> Unit,
+ content: @Composable () -> Unit,
+) {
+ JetchatTheme {
+ ModalNavigationDrawer(
+ drawerState = drawerState,
+ drawerContent = {
+ ModalDrawerSheet(
+ drawerState = drawerState,
+ drawerContainerColor = MaterialTheme.colorScheme.background,
+ drawerContentColor = MaterialTheme.colorScheme.onBackground,
+ ) {
+ JetchatDrawerContent(
+ onChatClicked = onChatClicked,
+ selectedMenu = selectedMenu,
+ )
+ }
+ },
+ content = content,
+ )
+ }
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/Conversation.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/Conversation.kt
new file mode 100644
index 0000000..6ac55b9
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/Conversation.kt
@@ -0,0 +1,563 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package xyz.magicalbits.smsremote.conversation
+
+import android.content.ClipDescription
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.draganddrop.dragAndDropTarget
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.exclude
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.paddingFrom
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.ScaffoldDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draganddrop.DragAndDropEvent
+import androidx.compose.ui.draganddrop.DragAndDropTarget
+import androidx.compose.ui.draganddrop.mimeTypes
+import androidx.compose.ui.draganddrop.toAndroidDragEvent
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.LastBaseline
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.tooling.preview.Preview
+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
+
+/**
+ * Entry point for a conversation screen.
+ *
+ * @param uiState [ConversationUiState] that contains messages to display
+ * @param navigateToProfile User action when navigation to a profile is requested
+ * @param modifier [Modifier] to apply to this layout node
+ * @param onNavIconPressed Sends an event up when the user clicks on the menu
+ */
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+fun ConversationContent(
+ uiState: ConversationUiState,
+ navigateToProfile: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ onNavIconPressed: () -> Unit = { },
+) {
+ val authorMe = stringResource(R.string.author_me)
+ val timeNow = stringResource(id = R.string.now)
+
+ val scrollState = rememberLazyListState()
+ val topBarState = rememberTopAppBarState()
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState)
+ val scope = rememberCoroutineScope()
+
+ var background by remember {
+ mutableStateOf(Color.Transparent)
+ }
+
+ var borderStroke by remember {
+ mutableStateOf(Color.Transparent)
+ }
+
+ val dragAndDropCallback = remember {
+ object : DragAndDropTarget {
+ override fun onDrop(event: DragAndDropEvent): Boolean {
+ val clipData = event.toAndroidDragEvent().clipData
+
+ if (clipData.itemCount < 1) {
+ return false
+ }
+
+ uiState.addMessage(
+ Message(authorMe, clipData.getItemAt(0).text.toString(), timeNow),
+ )
+
+ return true
+ }
+
+ override fun onStarted(event: DragAndDropEvent) {
+ super.onStarted(event)
+ borderStroke = Color.Red
+ }
+
+ override fun onEntered(event: DragAndDropEvent) {
+ super.onEntered(event)
+ background = Color.Red.copy(alpha = .3f)
+ }
+
+ override fun onExited(event: DragAndDropEvent) {
+ super.onExited(event)
+ background = Color.Transparent
+ }
+
+ override fun onEnded(event: DragAndDropEvent) {
+ super.onEnded(event)
+ background = Color.Transparent
+ borderStroke = Color.Transparent
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ ChannelNameBar(
+ channelName = uiState.channelName,
+ channelMembers = uiState.channelMembers,
+ onNavIconPressed = onNavIconPressed,
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ // Exclude ime and navigation bar padding so this can be added by the UserInput composable
+ contentWindowInsets = ScaffoldDefaults
+ .contentWindowInsets
+ .exclude(WindowInsets.navigationBars)
+ .exclude(WindowInsets.ime),
+ modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ ) { paddingValues ->
+ Column(
+ Modifier.fillMaxSize().padding(paddingValues)
+ .background(color = background)
+ .border(width = 2.dp, color = borderStroke)
+ .dragAndDropTarget(shouldStartDragAndDrop = { event ->
+ event
+ .mimeTypes()
+ .contains(
+ ClipDescription.MIMETYPE_TEXT_PLAIN,
+ )
+ }, target = dragAndDropCallback),
+ ) {
+ Messages(
+ messages = uiState.messages,
+ navigateToProfile = navigateToProfile,
+ modifier = Modifier.weight(1f),
+ scrollState = scrollState,
+ )
+ UserInput(
+ onMessageSent = { content ->
+ uiState.addMessage(
+ Message(authorMe, content, timeNow),
+ )
+ },
+ resetScroll = {
+ scope.launch {
+ scrollState.scrollToItem(0)
+ }
+ },
+ // let this element handle the padding so that the elevation is shown behind the
+ // navigation bar
+ modifier = Modifier.navigationBarsPadding().imePadding(),
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChannelNameBar(
+ channelName: String,
+ channelMembers: Int,
+ modifier: Modifier = Modifier,
+ scrollBehavior: TopAppBarScrollBehavior? = null,
+ onNavIconPressed: () -> Unit = { },
+) {
+ var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
+ if (functionalityNotAvailablePopupShown) {
+ FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
+ }
+ JetchatAppBar(
+ modifier = modifier,
+ scrollBehavior = scrollBehavior,
+ onNavIconPressed = onNavIconPressed,
+ title = {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ // Channel name
+ Text(
+ text = channelName,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ // Number of members
+ Text(
+ text = stringResource(R.string.members, channelMembers),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ },
+ actions = {
+ // Search icon
+ Icon(
+ painterResource(id = R.drawable.ic_search),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .clickable(onClick = { functionalityNotAvailablePopupShown = true })
+ .padding(horizontal = 12.dp, vertical = 16.dp)
+ .height(24.dp),
+ contentDescription = stringResource(id = R.string.search),
+ )
+ // Info icon
+ Icon(
+ painterResource(id = R.drawable.ic_info),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .clickable(onClick = { functionalityNotAvailablePopupShown = true })
+ .padding(horizontal = 12.dp, vertical = 16.dp)
+ .height(24.dp),
+ contentDescription = stringResource(id = R.string.info),
+ )
+ },
+ )
+}
+
+const val ConversationTestTag = "ConversationTestTag"
+
+@Composable
+fun Messages(messages: List, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier) {
+ val scope = rememberCoroutineScope()
+ Box(modifier = modifier) {
+
+ val authorMe = stringResource(id = R.string.author_me)
+ LazyColumn(
+ reverseLayout = true,
+ state = scrollState,
+ modifier = Modifier
+ .testTag(ConversationTestTag)
+ .fillMaxSize(),
+ ) {
+ for (index in messages.indices) {
+ val prevAuthor = messages.getOrNull(index - 1)?.author
+ val nextAuthor = messages.getOrNull(index + 1)?.author
+ val content = messages[index]
+ val isFirstMessageByAuthor = prevAuthor != content.author
+ val isLastMessageByAuthor = nextAuthor != content.author
+
+ // Hardcode day dividers for simplicity
+ if (index == messages.size - 1) {
+ item {
+ DayHeader("20 Aug")
+ }
+ } else if (index == 2) {
+ item {
+ DayHeader("Today")
+ }
+ }
+
+ item {
+ Message(
+ onAuthorClick = { name -> navigateToProfile(name) },
+ msg = content,
+ isUserMe = content.author == authorMe,
+ isFirstMessageByAuthor = isFirstMessageByAuthor,
+ isLastMessageByAuthor = isLastMessageByAuthor,
+ )
+ }
+ }
+ }
+ // Jump to bottom button shows up when user scrolls past a threshold.
+ // Convert to pixels:
+ val jumpThreshold = with(LocalDensity.current) {
+ JumpToBottomThreshold.toPx()
+ }
+
+ // Show the button if the first visible item is not the first one or if the offset is
+ // greater than the threshold.
+ val jumpToBottomButtonEnabled by remember {
+ derivedStateOf {
+ scrollState.firstVisibleItemIndex != 0 ||
+ scrollState.firstVisibleItemScrollOffset > jumpThreshold
+ }
+ }
+
+ JumpToBottom(
+ // Only show if the scroller is not at the bottom
+ enabled = jumpToBottomButtonEnabled,
+ onClicked = {
+ scope.launch {
+ scrollState.animateScrollToItem(0)
+ }
+ },
+ modifier = Modifier.align(Alignment.BottomCenter),
+ )
+ }
+}
+
+@Composable
+fun Message(
+ onAuthorClick: (String) -> Unit,
+ msg: Message,
+ isUserMe: Boolean,
+ isFirstMessageByAuthor: Boolean,
+ isLastMessageByAuthor: Boolean,
+) {
+ val borderColor = if (isUserMe) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.tertiary
+ }
+
+ val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier
+ Row(modifier = spaceBetweenAuthors) {
+ if (isLastMessageByAuthor) {
+ // Avatar
+ Image(
+ modifier = Modifier
+ .clickable(onClick = { onAuthorClick(msg.author) })
+ .padding(horizontal = 16.dp)
+ .size(42.dp)
+ .border(1.5.dp, borderColor, CircleShape)
+ .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape)
+ .clip(CircleShape)
+ .align(Alignment.Top),
+ painter = painterResource(id = msg.authorImage),
+ contentScale = ContentScale.Crop,
+ contentDescription = null,
+ )
+ } else {
+ // Space under avatar
+ Spacer(modifier = Modifier.width(74.dp))
+ }
+ AuthorAndTextMessage(
+ msg = msg,
+ isUserMe = isUserMe,
+ isFirstMessageByAuthor = isFirstMessageByAuthor,
+ isLastMessageByAuthor = isLastMessageByAuthor,
+ authorClicked = onAuthorClick,
+ modifier = Modifier
+ .padding(end = 16.dp)
+ .weight(1f),
+ )
+ }
+}
+
+@Composable
+fun AuthorAndTextMessage(
+ msg: Message,
+ isUserMe: Boolean,
+ isFirstMessageByAuthor: Boolean,
+ isLastMessageByAuthor: Boolean,
+ authorClicked: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ if (isLastMessageByAuthor) {
+ AuthorNameTimestamp(msg)
+ }
+ ChatItemBubble(msg, isUserMe, authorClicked = authorClicked)
+ if (isFirstMessageByAuthor) {
+ // Last bubble before next author
+ Spacer(modifier = Modifier.height(8.dp))
+ } else {
+ // Between bubbles
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ }
+}
+
+@Composable
+private fun AuthorNameTimestamp(msg: Message) {
+ // Combine author and timestamp for a11y.
+ Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
+ Text(
+ text = msg.author,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier
+ .alignBy(LastBaseline)
+ .paddingFrom(LastBaseline, after = 8.dp), // Space to 1st bubble
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = msg.timestamp,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.alignBy(LastBaseline),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
+
+@Composable
+fun DayHeader(dayString: String) {
+ Row(
+ modifier = Modifier
+ .padding(vertical = 8.dp, horizontal = 16.dp)
+ .height(16.dp),
+ ) {
+ DayHeaderLine()
+ Text(
+ text = dayString,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ DayHeaderLine()
+ }
+}
+
+@Composable
+private fun RowScope.DayHeaderLine() {
+ HorizontalDivider(
+ modifier = Modifier
+ .weight(1f)
+ .align(Alignment.CenterVertically),
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
+ )
+}
+
+@Composable
+fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) {
+
+ val backgroundBubbleColor = if (isUserMe) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ }
+
+ Column {
+ Surface(
+ color = backgroundBubbleColor,
+ shape = ChatBubbleShape,
+ ) {
+ ClickableMessage(
+ message = message,
+ isUserMe = isUserMe,
+ authorClicked = authorClicked,
+ )
+ }
+
+ message.image?.let {
+ Spacer(modifier = Modifier.height(4.dp))
+ Surface(
+ color = backgroundBubbleColor,
+ shape = ChatBubbleShape,
+ ) {
+ Image(
+ painter = painterResource(it),
+ contentScale = ContentScale.Fit,
+ modifier = Modifier.size(160.dp),
+ contentDescription = stringResource(id = R.string.attached_image),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) {
+ val uriHandler = LocalUriHandler.current
+
+ val styledMessage = messageFormatter(
+ text = message.content,
+ primary = isUserMe,
+ )
+
+ ClickableText(
+ text = styledMessage,
+ style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current),
+ modifier = Modifier.padding(16.dp),
+ onClick = {
+ styledMessage
+ .getStringAnnotations(start = it, end = it)
+ .firstOrNull()
+ ?.let { annotation ->
+ when (annotation.tag) {
+ SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item)
+ SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item)
+ else -> Unit
+ }
+ }
+ },
+ )
+}
+
+@Preview
+@Composable
+fun ConversationPreview() {
+ JetchatTheme {
+ ConversationContent(
+ uiState = exampleUiState,
+ navigateToProfile = { },
+ )
+ }
+}
+
+@Preview
+@Composable
+fun ChannelBarPrev() {
+ JetchatTheme {
+ ChannelNameBar(channelName = "composers", channelMembers = 52)
+ }
+}
+
+@Preview
+@Composable
+fun DayHeaderPrev() {
+ DayHeader("Aug 6")
+}
+
+private val JumpToBottomThreshold = 56.dp
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationFragment.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationFragment.kt
new file mode 100644
index 0000000..b946e66
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationFragment.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.conversation
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import androidx.compose.ui.platform.ComposeView
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.findNavController
+import xyz.magicalbits.smsremote.MainViewModel
+import xyz.magicalbits.smsremote.R
+import xyz.magicalbits.smsremote.data.exampleUiState
+import xyz.magicalbits.smsremote.data.exampleUiState2
+import xyz.magicalbits.smsremote.theme.JetchatTheme
+
+class ConversationFragment : Fragment() {
+ private val activityViewModel: MainViewModel by activityViewModels()
+
+ var phoneNumber: String = ""
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ // Consider using safe args plugin
+ val phoneNumber = arguments?.getString("phoneNumber")
+// viewModel.setDeviceId(deviceId)
+ this.phoneNumber = phoneNumber!!
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View =
+ ComposeView(inflater.context).apply {
+ layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
+
+ val uiState =
+ if (phoneNumber == "+420123456789") {
+ exampleUiState
+ } else {
+ exampleUiState2
+ }
+ uiState.channelName = phoneNumber
+
+ setContent {
+ JetchatTheme {
+ ConversationContent(
+ uiState = uiState,
+ navigateToProfile = { user ->
+ // Click callback
+ val bundle = bundleOf("userId" to user)
+ findNavController().navigate(
+ R.id.nav_profile,
+ bundle,
+ )
+ },
+ onNavIconPressed = {
+ activityViewModel.openDrawer()
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationUiState.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationUiState.kt
new file mode 100644
index 0000000..64cf2ec
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationUiState.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.conversation
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.toMutableStateList
+import xyz.magicalbits.smsremote.R
+
+class ConversationUiState(
+ var channelName: String,
+ val channelMembers: Int,
+ initialMessages: List,
+) {
+ private val _messages: MutableList = initialMessages.toMutableStateList()
+ val messages: List = _messages
+
+ fun addMessage(msg: Message) {
+ _messages.add(0, msg) // Add to the beginning of the list
+ }
+}
+
+@Immutable
+data class Message(
+ val author: String,
+ val content: String,
+ val timestamp: String,
+ val image: Int? = null,
+ val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else,
+)
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/JumpToBottom.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/JumpToBottom.kt
new file mode 100644
index 0000000..0e4ff9a
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/JumpToBottom.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.conversation
+
+import androidx.compose.animation.core.animateDp
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import xyz.magicalbits.smsremote.R
+
+private enum class Visibility {
+ VISIBLE,
+ GONE,
+}
+
+/**
+ * Shows a button that lets the user scroll to the bottom.
+ */
+@Composable
+fun JumpToBottom(enabled: Boolean, onClicked: () -> Unit, modifier: Modifier = Modifier) {
+ // Show Jump to Bottom button
+ val transition = updateTransition(
+ if (enabled) Visibility.VISIBLE else Visibility.GONE,
+ label = "JumpToBottom visibility animation",
+ )
+ val bottomOffset by transition.animateDp(label = "JumpToBottom offset animation") {
+ if (it == Visibility.GONE) {
+ (-32).dp
+ } else {
+ 32.dp
+ }
+ }
+ if (bottomOffset > 0.dp) {
+ ExtendedFloatingActionButton(
+ icon = {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_arrow_downward),
+ modifier = Modifier.height(18.dp),
+ contentDescription = null,
+ )
+ },
+ text = {
+ Text(text = stringResource(id = R.string.jumpBottom))
+ },
+ onClick = onClicked,
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = MaterialTheme.colorScheme.primary,
+ modifier = modifier
+ .offset(x = 0.dp, y = -bottomOffset)
+ .height(36.dp),
+ )
+ }
+}
+
+@Preview
+@Composable
+fun JumpToBottomPreview() {
+ JumpToBottom(enabled = true, onClicked = {})
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/MessageFormatter.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/MessageFormatter.kt
new file mode 100644
index 0000000..9c2adb3
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/MessageFormatter.kt
@@ -0,0 +1,204 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.conversation
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.BaselineShift
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.sp
+
+// Regex containing the syntax tokens
+val symbolPattern by lazy {
+ Regex("""(https?://[^\s\t\n]+)|(`[^`]+`)|(@\w+)|(\*[\w]+\*)|(_[\w]+_)|(~[\w]+~)""")
+}
+
+// Accepted annotations for the ClickableTextWrapper
+enum class SymbolAnnotationType {
+ PERSON,
+ LINK,
+}
+typealias StringAnnotation = AnnotatedString.Range
+
+// Pair returning styled content and annotation for ClickableText when matching syntax token
+typealias SymbolAnnotation = Pair
+
+/**
+ * Format a message following Markdown-lite syntax
+ * | @username -> bold, primary color and clickable element
+ * | http(s)://... -> clickable link, opening it into the browser
+ * | *bold* -> bold
+ * | _italic_ -> italic
+ * | ~strikethrough~ -> strikethrough
+ * | `MyClass.myMethod` -> inline code styling
+ *
+ * @param text contains message to be parsed
+ * @return AnnotatedString with annotations used inside the ClickableText wrapper
+ */
+@Composable
+fun messageFormatter(
+ text: String,
+ primary: Boolean,
+): AnnotatedString {
+ val tokens = symbolPattern.findAll(text)
+
+ return buildAnnotatedString {
+ var cursorPosition = 0
+
+ val codeSnippetBackground =
+ if (primary) {
+ MaterialTheme.colorScheme.secondary
+ } else {
+ MaterialTheme.colorScheme.surface
+ }
+
+ for (token in tokens) {
+ append(text.slice(cursorPosition until token.range.first))
+
+ val (annotatedString, stringAnnotation) =
+ getSymbolAnnotation(
+ matchResult = token,
+ colorScheme = MaterialTheme.colorScheme,
+ primary = primary,
+ codeSnippetBackground = codeSnippetBackground,
+ )
+ append(annotatedString)
+
+ if (stringAnnotation != null) {
+ val (item, start, end, tag) = stringAnnotation
+ addStringAnnotation(tag = tag, start = start, end = end, annotation = item)
+ }
+
+ cursorPosition = token.range.last + 1
+ }
+
+ if (!tokens.none()) {
+ append(text.slice(cursorPosition..text.lastIndex))
+ } else {
+ append(text)
+ }
+ }
+}
+
+/**
+ * Map regex matches found in a message with supported syntax symbols
+ *
+ * @param matchResult is a regex result matching our syntax symbols
+ * @return pair of AnnotatedString with annotation (optional) used inside the ClickableText wrapper
+ */
+private fun getSymbolAnnotation(
+ matchResult: MatchResult,
+ colorScheme: ColorScheme,
+ primary: Boolean,
+ codeSnippetBackground: Color,
+): SymbolAnnotation =
+ when (matchResult.value.first()) {
+ '@' -> {
+ SymbolAnnotation(
+ AnnotatedString(
+ text = matchResult.value,
+ spanStyle =
+ SpanStyle(
+ color = if (primary) colorScheme.inversePrimary else colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ ),
+ ),
+ StringAnnotation(
+ item = matchResult.value.substring(1),
+ start = matchResult.range.first,
+ end = matchResult.range.last,
+ tag = SymbolAnnotationType.PERSON.name,
+ ),
+ )
+ }
+
+ '*' -> {
+ SymbolAnnotation(
+ AnnotatedString(
+ text = matchResult.value.trim('*'),
+ spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
+ ),
+ null,
+ )
+ }
+
+ '_' -> {
+ SymbolAnnotation(
+ AnnotatedString(
+ text = matchResult.value.trim('_'),
+ spanStyle = SpanStyle(fontStyle = FontStyle.Italic),
+ ),
+ null,
+ )
+ }
+
+ '~' -> {
+ SymbolAnnotation(
+ AnnotatedString(
+ text = matchResult.value.trim('~'),
+ spanStyle = SpanStyle(textDecoration = TextDecoration.LineThrough),
+ ),
+ null,
+ )
+ }
+
+ '`' -> {
+ SymbolAnnotation(
+ AnnotatedString(
+ text = matchResult.value.trim('`'),
+ spanStyle =
+ SpanStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 12.sp,
+ background = codeSnippetBackground,
+ baselineShift = BaselineShift(0.2f),
+ ),
+ ),
+ null,
+ )
+ }
+
+ 'h' -> {
+ SymbolAnnotation(
+ AnnotatedString(
+ text = matchResult.value,
+ spanStyle =
+ SpanStyle(
+ color = if (primary) colorScheme.inversePrimary else colorScheme.primary,
+ ),
+ ),
+ StringAnnotation(
+ item = matchResult.value,
+ start = matchResult.range.first,
+ end = matchResult.range.last,
+ tag = SymbolAnnotationType.LINK.name,
+ ),
+ )
+ }
+
+ else -> {
+ SymbolAnnotation(AnnotatedString(matchResult.value), null)
+ }
+ }
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/UserInput.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/UserInput.kt
new file mode 100644
index 0000000..e3c5a07
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/UserInput.kt
@@ -0,0 +1,772 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.conversation
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+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.layout.paddingFrom
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusTarget
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.FirstBaseline
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.SemanticsPropertyKey
+import androidx.compose.ui.semantics.SemanticsPropertyReceiver
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
+import xyz.magicalbits.smsremote.R
+import kotlin.math.absoluteValue
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.delay
+
+enum class InputSelector {
+ NONE,
+ MAP,
+ DM,
+ EMOJI,
+ PHONE,
+ PICTURE,
+}
+
+enum class EmojiStickerSelector {
+ EMOJI,
+ STICKER,
+}
+
+@Preview
+@Composable
+fun UserInputPreview() {
+ UserInput(onMessageSent = {})
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun UserInput(onMessageSent: (String) -> Unit, modifier: Modifier = Modifier, resetScroll: () -> Unit = {}) {
+ var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) }
+ val dismissKeyboard = { currentInputSelector = InputSelector.NONE }
+
+ // Intercept back navigation if there's a InputSelector visible
+ if (currentInputSelector != InputSelector.NONE) {
+ BackHandler(onBack = dismissKeyboard)
+ }
+
+ var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(TextFieldValue())
+ }
+
+ // Used to decide if the keyboard should be shown
+ var textFieldFocusState by remember { mutableStateOf(false) }
+
+ Surface(tonalElevation = 2.dp, contentColor = MaterialTheme.colorScheme.secondary) {
+ Column(modifier = modifier) {
+ UserInputText(
+ textFieldValue = textState,
+ onTextChanged = { textState = it },
+ // Only show the keyboard if there's no input selector and text field has focus
+ keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState,
+ // Close extended selector if text field receives focus
+ onTextFieldFocused = { focused ->
+ if (focused) {
+ currentInputSelector = InputSelector.NONE
+ resetScroll()
+ }
+ textFieldFocusState = focused
+ },
+ onMessageSent = {
+ onMessageSent(textState.text)
+ // Reset text field and close keyboard
+ textState = TextFieldValue()
+ // Move scroll to bottom
+ resetScroll()
+ },
+ focusState = textFieldFocusState,
+ )
+ UserInputSelector(
+ onSelectorChange = { currentInputSelector = it },
+ sendMessageEnabled = textState.text.isNotBlank(),
+ onMessageSent = {
+ onMessageSent(textState.text)
+ // Reset text field and close keyboard
+ textState = TextFieldValue()
+ // Move scroll to bottom
+ resetScroll()
+ dismissKeyboard()
+ },
+ currentInputSelector = currentInputSelector,
+ )
+ SelectorExpanded(
+ onCloseRequested = dismissKeyboard,
+ onTextAdded = { textState = textState.addText(it) },
+ currentSelector = currentInputSelector,
+ )
+ }
+ }
+}
+
+private fun TextFieldValue.addText(newString: String): TextFieldValue {
+ val newText = this.text.replaceRange(
+ this.selection.start,
+ this.selection.end,
+ newString,
+ )
+ val newSelection = TextRange(
+ start = newText.length,
+ end = newText.length,
+ )
+
+ return this.copy(text = newText, selection = newSelection)
+}
+
+@Composable
+private fun SelectorExpanded(currentSelector: InputSelector, onCloseRequested: () -> Unit, onTextAdded: (String) -> Unit) {
+ if (currentSelector == InputSelector.NONE) return
+
+ // Request focus to force the TextField to lose it
+ val focusRequester = remember { FocusRequester() }
+ // If the selector is shown, always request focus to trigger a TextField.onFocusChange.
+ SideEffect {
+ if (currentSelector == InputSelector.EMOJI) {
+ focusRequester.requestFocus()
+ }
+ }
+
+ Surface(tonalElevation = 8.dp) {
+ when (currentSelector) {
+ InputSelector.EMOJI -> EmojiSelector(onTextAdded, focusRequester)
+ InputSelector.DM -> NotAvailablePopup(onCloseRequested)
+ InputSelector.PICTURE -> FunctionalityNotAvailablePanel()
+ InputSelector.MAP -> FunctionalityNotAvailablePanel()
+ InputSelector.PHONE -> FunctionalityNotAvailablePanel()
+ InputSelector.NONE -> Unit
+ }
+ }
+}
+
+@Composable
+fun FunctionalityNotAvailablePanel() {
+ AnimatedVisibility(
+ visibleState = remember { MutableTransitionState(false).apply { targetState = true } },
+ enter = expandHorizontally() + fadeIn(),
+ exit = shrinkHorizontally() + fadeOut(),
+ ) {
+ Column(
+ modifier = Modifier
+ .height(320.dp)
+ .fillMaxWidth(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(id = R.string.not_available),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(
+ text = stringResource(id = R.string.not_available_subtitle),
+ modifier = Modifier.paddingFrom(FirstBaseline, before = 32.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Composable
+private fun UserInputSelector(
+ onSelectorChange: (InputSelector) -> Unit,
+ sendMessageEnabled: Boolean,
+ onMessageSent: () -> Unit,
+ currentInputSelector: InputSelector,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .height(72.dp)
+ .wrapContentHeight()
+ .padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ InputSelectorButton(
+ onClick = { onSelectorChange(InputSelector.EMOJI) },
+ icon = painterResource(id = R.drawable.ic_mood),
+ selected = currentInputSelector == InputSelector.EMOJI,
+ description = stringResource(id = R.string.emoji_selector_bt_desc),
+ )
+ InputSelectorButton(
+ onClick = { onSelectorChange(InputSelector.DM) },
+ icon = painterResource(id = R.drawable.ic_alternate_email),
+ selected = currentInputSelector == InputSelector.DM,
+ description = stringResource(id = R.string.dm_desc),
+ )
+ InputSelectorButton(
+ onClick = { onSelectorChange(InputSelector.PICTURE) },
+ icon = painterResource(id = R.drawable.ic_insert_photo),
+ selected = currentInputSelector == InputSelector.PICTURE,
+ description = stringResource(id = R.string.attach_photo_desc),
+ )
+ InputSelectorButton(
+ onClick = { onSelectorChange(InputSelector.MAP) },
+ icon = painterResource(id = R.drawable.ic_place),
+ selected = currentInputSelector == InputSelector.MAP,
+ description = stringResource(id = R.string.map_selector_desc),
+ )
+ InputSelectorButton(
+ onClick = { onSelectorChange(InputSelector.PHONE) },
+ icon = painterResource(id = R.drawable.ic_duo),
+ selected = currentInputSelector == InputSelector.PHONE,
+ description = stringResource(id = R.string.videochat_desc),
+ )
+
+ val border = if (!sendMessageEnabled) {
+ BorderStroke(
+ width = 1.dp,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
+ )
+ } else {
+ null
+ }
+ Spacer(modifier = Modifier.weight(1f))
+
+ val disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
+
+ val buttonColors = ButtonDefaults.buttonColors(
+ disabledContainerColor = Color.Transparent,
+ disabledContentColor = disabledContentColor,
+ )
+
+ // Send button
+ Button(
+ modifier = Modifier.height(36.dp),
+ enabled = sendMessageEnabled,
+ onClick = onMessageSent,
+ colors = buttonColors,
+ border = border,
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Text(
+ stringResource(id = R.string.send),
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun InputSelectorButton(
+ onClick: () -> Unit,
+ icon: androidx.compose.ui.graphics.painter.Painter,
+ description: String,
+ selected: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val backgroundModifier = if (selected) {
+ Modifier.background(
+ color = LocalContentColor.current,
+ shape = RoundedCornerShape(14.dp),
+ )
+ } else {
+ Modifier
+ }
+ IconButton(
+ onClick = onClick,
+ modifier = modifier.then(backgroundModifier),
+ ) {
+ val tint = if (selected) {
+ contentColorFor(backgroundColor = LocalContentColor.current)
+ } else {
+ LocalContentColor.current
+ }
+ Icon(
+ icon,
+ tint = tint,
+ modifier = Modifier
+ .padding(8.dp)
+ .size(56.dp),
+ contentDescription = description,
+ )
+ }
+}
+
+@Composable
+private fun NotAvailablePopup(onDismissed: () -> Unit) {
+ FunctionalityNotAvailablePopup(onDismissed)
+}
+
+val KeyboardShownKey = SemanticsPropertyKey("KeyboardShownKey")
+var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey
+
+@OptIn(ExperimentalAnimationApi::class)
+@ExperimentalFoundationApi
+@Composable
+private fun UserInputText(
+ keyboardType: KeyboardType = KeyboardType.Text,
+ onTextChanged: (TextFieldValue) -> Unit,
+ textFieldValue: TextFieldValue,
+ keyboardShown: Boolean,
+ onTextFieldFocused: (Boolean) -> Unit,
+ onMessageSent: (String) -> Unit,
+ focusState: Boolean,
+) {
+ val swipeOffset = remember { mutableStateOf(0f) }
+ var isRecordingMessage by remember { mutableStateOf(false) }
+ val a11ylabel = stringResource(id = R.string.textfield_desc)
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(64.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ AnimatedContent(
+ targetState = isRecordingMessage,
+ label = "text-field",
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ ) { recording ->
+ Box(Modifier.fillMaxSize()) {
+ if (recording) {
+ RecordingIndicator { swipeOffset.value }
+ } else {
+ UserInputTextField(
+ textFieldValue,
+ onTextChanged,
+ onTextFieldFocused,
+ keyboardType,
+ focusState,
+ onMessageSent,
+ Modifier.fillMaxWidth().semantics {
+ contentDescription = a11ylabel
+ keyboardShownProperty = keyboardShown
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun BoxScope.UserInputTextField(
+ textFieldValue: TextFieldValue,
+ onTextChanged: (TextFieldValue) -> Unit,
+ onTextFieldFocused: (Boolean) -> Unit,
+ keyboardType: KeyboardType,
+ focusState: Boolean,
+ onMessageSent: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var lastFocusState by remember { mutableStateOf(false) }
+ BasicTextField(
+ value = textFieldValue,
+ onValueChange = { onTextChanged(it) },
+ modifier = modifier
+ .padding(start = 32.dp)
+ .align(Alignment.CenterStart)
+ .onFocusChanged { state ->
+ if (lastFocusState != state.isFocused) {
+ onTextFieldFocused(state.isFocused)
+ }
+ lastFocusState = state.isFocused
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = keyboardType,
+ imeAction = ImeAction.Send,
+ ),
+ keyboardActions = KeyboardActions {
+ if (textFieldValue.text.isNotBlank()) onMessageSent(textFieldValue.text)
+ },
+ maxLines = 1,
+ cursorBrush = SolidColor(LocalContentColor.current),
+ textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current),
+ )
+
+ val disableContentColor =
+ MaterialTheme.colorScheme.onSurfaceVariant
+ if (textFieldValue.text.isEmpty() && !focusState) {
+ Text(
+ modifier = Modifier
+ .align(Alignment.CenterStart)
+ .padding(start = 32.dp),
+ text = stringResource(R.string.textfield_hint),
+ style = MaterialTheme.typography.bodyLarge.copy(color = disableContentColor),
+ )
+ }
+}
+
+@Composable
+private fun RecordingIndicator(swipeOffset: () -> Float) {
+ var duration by remember { mutableStateOf(Duration.ZERO) }
+ LaunchedEffect(Unit) {
+ while (true) {
+ delay(1000)
+ duration += 1.seconds
+ }
+ }
+ Row(
+ Modifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val infiniteTransition = rememberInfiniteTransition(label = "pulse")
+
+ val animatedPulse = infiniteTransition.animateFloat(
+ initialValue = 1f,
+ targetValue = 0.2f,
+ animationSpec = infiniteRepeatable(
+ tween(2000),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "pulse",
+ )
+ Box(
+ Modifier
+ .size(56.dp)
+ .padding(24.dp)
+ .graphicsLayer {
+ scaleX = animatedPulse.value
+ scaleY = animatedPulse.value
+ }
+ .clip(CircleShape)
+ .background(Color.Red),
+ )
+ Text(
+ duration.toComponents { minutes, seconds, _ ->
+ val min = minutes.toString().padStart(2, '0')
+ val sec = seconds.toString().padStart(2, '0')
+ "$min:$sec"
+ },
+ Modifier.alignByBaseline(),
+ )
+ Box(
+ Modifier
+ .fillMaxSize()
+ .alignByBaseline()
+ .clipToBounds(),
+ ) {
+ val swipeThreshold = with(LocalDensity.current) { 200.dp.toPx() }
+ Text(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .graphicsLayer {
+ translationX = swipeOffset() / 2
+ alpha = 1 - (swipeOffset().absoluteValue / swipeThreshold)
+ },
+ textAlign = TextAlign.Center,
+ text = stringResource(R.string.swipe_to_cancel_recording),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ }
+}
+
+@Composable
+fun EmojiSelector(onTextAdded: (String) -> Unit, focusRequester: FocusRequester) {
+ var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) }
+
+ val a11yLabel = stringResource(id = R.string.emoji_selector_desc)
+ Column(
+ modifier = Modifier
+ .focusRequester(focusRequester) // Requests focus when the Emoji selector is displayed
+ // Make the emoji selector focusable so it can steal focus from TextField
+ .focusTarget()
+ .semantics { contentDescription = a11yLabel },
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp),
+ ) {
+ ExtendedSelectorInnerButton(
+ text = stringResource(id = R.string.emojis_label),
+ onClick = { selected = EmojiStickerSelector.EMOJI },
+ selected = true,
+ modifier = Modifier.weight(1f),
+ )
+ ExtendedSelectorInnerButton(
+ text = stringResource(id = R.string.stickers_label),
+ onClick = { selected = EmojiStickerSelector.STICKER },
+ selected = false,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ Row(modifier = Modifier.verticalScroll(rememberScrollState())) {
+ EmojiTable(onTextAdded, modifier = Modifier.padding(8.dp))
+ }
+ }
+ if (selected == EmojiStickerSelector.STICKER) {
+ NotAvailablePopup(onDismissed = { selected = EmojiStickerSelector.EMOJI })
+ }
+}
+
+@Composable
+fun ExtendedSelectorInnerButton(text: String, onClick: () -> Unit, selected: Boolean, modifier: Modifier = Modifier) {
+ val colors = ButtonDefaults.buttonColors(
+ containerColor = if (selected) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
+ else Color.Transparent,
+ disabledContainerColor = Color.Transparent,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.74f),
+ )
+ TextButton(
+ onClick = onClick,
+ modifier = modifier
+ .padding(8.dp)
+ .height(36.dp),
+ colors = colors,
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ }
+}
+
+@Composable
+fun EmojiTable(onTextAdded: (String) -> Unit, modifier: Modifier = Modifier) {
+ Column(modifier.fillMaxWidth()) {
+ repeat(4) { x ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ repeat(EMOJI_COLUMNS) { y ->
+ val emoji = emojis[x * EMOJI_COLUMNS + y]
+ Text(
+ modifier = Modifier
+ .clickable(onClick = { onTextAdded(emoji) })
+ .sizeIn(minWidth = 42.dp, minHeight = 42.dp)
+ .padding(8.dp),
+ text = emoji,
+ style = LocalTextStyle.current.copy(
+ fontSize = 18.sp,
+ textAlign = TextAlign.Center,
+ ),
+ )
+ }
+ }
+ }
+ }
+}
+
+private const val EMOJI_COLUMNS = 10
+
+private val emojis = listOf(
+ "\ud83d\ude00", // Grinning Face
+ "\ud83d\ude01", // Grinning Face With Smiling Eyes
+ "\ud83d\ude02", // Face With Tears of Joy
+ "\ud83d\ude03", // Smiling Face With Open Mouth
+ "\ud83d\ude04", // Smiling Face With Open Mouth and Smiling Eyes
+ "\ud83d\ude05", // Smiling Face With Open Mouth and Cold Sweat
+ "\ud83d\ude06", // Smiling Face With Open Mouth and Tightly-Closed Eyes
+ "\ud83d\ude09", // Winking Face
+ "\ud83d\ude0a", // Smiling Face With Smiling Eyes
+ "\ud83d\ude0b", // Face Savouring Delicious Food
+ "\ud83d\ude0e", // Smiling Face With Sunglasses
+ "\ud83d\ude0d", // Smiling Face With Heart-Shaped Eyes
+ "\ud83d\ude18", // Face Throwing a Kiss
+ "\ud83d\ude17", // Kissing Face
+ "\ud83d\ude19", // Kissing Face With Smiling Eyes
+ "\ud83d\ude1a", // Kissing Face With Closed Eyes
+ "\u263a", // White Smiling Face
+ "\ud83d\ude42", // Slightly Smiling Face
+ "\ud83e\udd17", // Hugging Face
+ "\ud83d\ude07", // Smiling Face With Halo
+ "\ud83e\udd13", // Nerd Face
+ "\ud83e\udd14", // Thinking Face
+ "\ud83d\ude10", // Neutral Face
+ "\ud83d\ude11", // Expressionless Face
+ "\ud83d\ude36", // Face Without Mouth
+ "\ud83d\ude44", // Face With Rolling Eyes
+ "\ud83d\ude0f", // Smirking Face
+ "\ud83d\ude23", // Persevering Face
+ "\ud83d\ude25", // Disappointed but Relieved Face
+ "\ud83d\ude2e", // Face With Open Mouth
+ "\ud83e\udd10", // Zipper-Mouth Face
+ "\ud83d\ude2f", // Hushed Face
+ "\ud83d\ude2a", // Sleepy Face
+ "\ud83d\ude2b", // Tired Face
+ "\ud83d\ude34", // Sleeping Face
+ "\ud83d\ude0c", // Relieved Face
+ "\ud83d\ude1b", // Face With Stuck-Out Tongue
+ "\ud83d\ude1c", // Face With Stuck-Out Tongue and Winking Eye
+ "\ud83d\ude1d", // Face With Stuck-Out Tongue and Tightly-Closed Eyes
+ "\ud83d\ude12", // Unamused Face
+ "\ud83d\ude13", // Face With Cold Sweat
+ "\ud83d\ude14", // Pensive Face
+ "\ud83d\ude15", // Confused Face
+ "\ud83d\ude43", // Upside-Down Face
+ "\ud83e\udd11", // Money-Mouth Face
+ "\ud83d\ude32", // Astonished Face
+ "\ud83d\ude37", // Face With Medical Mask
+ "\ud83e\udd12", // Face With Thermometer
+ "\ud83e\udd15", // Face With Head-Bandage
+ "\u2639", // White Frowning Face
+ "\ud83d\ude41", // Slightly Frowning Face
+ "\ud83d\ude16", // Confounded Face
+ "\ud83d\ude1e", // Disappointed Face
+ "\ud83d\ude1f", // Worried Face
+ "\ud83d\ude24", // Face With Look of Triumph
+ "\ud83d\ude22", // Crying Face
+ "\ud83d\ude2d", // Loudly Crying Face
+ "\ud83d\ude26", // Frowning Face With Open Mouth
+ "\ud83d\ude27", // Anguished Face
+ "\ud83d\ude28", // Fearful Face
+ "\ud83d\ude29", // Weary Face
+ "\ud83d\ude2c", // Grimacing Face
+ "\ud83d\ude30", // Face With Open Mouth and Cold Sweat
+ "\ud83d\ude31", // Face Screaming in Fear
+ "\ud83d\ude33", // Flushed Face
+ "\ud83d\ude35", // Dizzy Face
+ "\ud83d\ude21", // Pouting Face
+ "\ud83d\ude20", // Angry Face
+ "\ud83d\ude08", // Smiling Face With Horns
+ "\ud83d\udc7f", // Imp
+ "\ud83d\udc79", // Japanese Ogre
+ "\ud83d\udc7a", // Japanese Goblin
+ "\ud83d\udc80", // Skull
+ "\ud83d\udc7b", // Ghost
+ "\ud83d\udc7d", // Extraterrestrial Alien
+ "\ud83e\udd16", // Robot Face
+ "\ud83d\udca9", // Pile of Poo
+ "\ud83d\ude3a", // Smiling Cat Face With Open Mouth
+ "\ud83d\ude38", // Grinning Cat Face With Smiling Eyes
+ "\ud83d\ude39", // Cat Face With Tears of Joy
+ "\ud83d\ude3b", // Smiling Cat Face With Heart-Shaped Eyes
+ "\ud83d\ude3c", // Cat Face With Wry Smile
+ "\ud83d\ude3d", // Kissing Cat Face With Closed Eyes
+ "\ud83d\ude40", // Weary Cat Face
+ "\ud83d\ude3f", // Crying Cat Face
+ "\ud83d\ude3e", // Pouting Cat Face
+ "\ud83d\udc66", // Boy
+ "\ud83d\udc67", // Girl
+ "\ud83d\udc68", // Man
+ "\ud83d\udc69", // Woman
+ "\ud83d\udc74", // Older Man
+ "\ud83d\udc75", // Older Woman
+ "\ud83d\udc76", // Baby
+ "\ud83d\udc71", // Person With Blond Hair
+ "\ud83d\udc6e", // Police Officer
+ "\ud83d\udc72", // Man With Gua Pi Mao
+ "\ud83d\udc73", // Man With Turban
+ "\ud83d\udc77", // Construction Worker
+ "\u26d1", // Helmet With White Cross
+ "\ud83d\udc78", // Princess
+ "\ud83d\udc82", // Guardsman
+ "\ud83d\udd75", // Sleuth or Spy
+ "\ud83c\udf85", // Father Christmas
+ "\ud83d\udc70", // Bride With Veil
+ "\ud83d\udc7c", // Baby Angel
+ "\ud83d\udc86", // Face Massage
+ "\ud83d\udc87", // Haircut
+ "\ud83d\ude4d", // Person Frowning
+ "\ud83d\ude4e", // Person With Pouting Face
+ "\ud83d\ude45", // Face With No Good Gesture
+ "\ud83d\ude46", // Face With OK Gesture
+ "\ud83d\udc81", // Information Desk Person
+ "\ud83d\ude4b", // Happy Person Raising One Hand
+ "\ud83d\ude47", // Person Bowing Deeply
+ "\ud83d\ude4c", // Person Raising Both Hands in Celebration
+ "\ud83d\ude4f", // Person With Folded Hands
+ "\ud83d\udde3", // Speaking Head in Silhouette
+ "\ud83d\udc64", // Bust in Silhouette
+ "\ud83d\udc65", // Busts in Silhouette
+ "\ud83d\udeb6", // Pedestrian
+ "\ud83c\udfc3", // Runner
+ "\ud83d\udc6f", // Woman With Bunny Ears
+ "\ud83d\udc83", // Dancer
+ "\ud83d\udd74", // Man in Business Suit Levitating
+ "\ud83d\udc6b", // Man and Woman Holding Hands
+ "\ud83d\udc6c", // Two Men Holding Hands
+ "\ud83d\udc6d", // Two Women Holding Hands
+ "\ud83d\udc8f", // Kiss
+)
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/data/FakeData.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/data/FakeData.kt
new file mode 100644
index 0000000..defc460
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/data/FakeData.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.data
+
+import xyz.magicalbits.smsremote.R
+import xyz.magicalbits.smsremote.conversation.ConversationUiState
+import xyz.magicalbits.smsremote.conversation.Message
+import xyz.magicalbits.smsremote.device.DeviceScreenState
+import xyz.magicalbits.smsremote.profile.ProfileScreenState
+
+val initialMessages =
+ listOf(
+ Message(
+ "Taylor Brooks",
+ "You can use all the same stuff",
+ "8:05 PM",
+ ),
+ )
+
+val initialMessages2 =
+ listOf(
+ Message(
+ "uahguoidahfg",
+ "yolo",
+ "8:05 PM",
+ ),
+ )
+
+val unreadMessages = initialMessages.filter { it.author != "me" }
+
+val exampleUiState =
+ ConversationUiState(
+ initialMessages = initialMessages,
+ channelName = "Samsung A14",
+ channelMembers = 42,
+ )
+
+val exampleUiState2 =
+ ConversationUiState(
+ initialMessages = initialMessages2,
+ channelName = "iPhone XYZ",
+ channelMembers = 69,
+ )
+
+/**
+ * Example colleague profile
+ */
+val colleagueProfile =
+ ProfileScreenState(
+ userId = "12345",
+ photo = R.drawable.someone_else,
+ name = "Taylor Brooks",
+ status = "Away",
+ displayName = "taylor",
+ position = "Senior Android Dev at Openlane",
+ twitter = "twitter.com/taylorbrookscodes",
+ timeZone = "12:25 AM local time (Eastern Daylight Time)",
+ commonChannels = "2",
+ )
+
+/**
+ * Example "me" profile.
+ */
+val meProfile =
+ ProfileScreenState(
+ userId = "me",
+ photo = R.drawable.ali,
+ name = "Ali Conors",
+ status = "Online",
+ displayName = "aliconors",
+ position = "Senior Android Dev at Yearin\nGoogle Developer Expert",
+ twitter = "twitter.com/aliconors",
+ timeZone = "In your timezone",
+ commonChannels = null,
+ )
+
+val a14Device =
+ DeviceScreenState(
+ deviceId = "012345",
+ name = "Samsung A14",
+ phoneNumbers = listOf("+420123456789", "+420777444111")
+ )
+
+val iPhoneDevice =
+ DeviceScreenState(
+ deviceId = "012345",
+ name = "iPhone XYZ",
+ phoneNumbers = listOf("+15558881111")
+ )
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/device/Device.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/Device.kt
new file mode 100644
index 0000000..a44c506
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/Device.kt
@@ -0,0 +1,97 @@
+package xyz.magicalbits.smsremote.device
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+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.systemBarsPadding
+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.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import xyz.magicalbits.smsremote.R
+import xyz.magicalbits.smsremote.components.baselineHeight
+
+@Composable
+fun DeviceScreen(
+ deviceData: DeviceScreenState,
+ nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
+ onPhoneNumberClicked: (String) -> Unit,
+) {
+ val scrollState = rememberScrollState()
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(nestedScrollInteropConnection),
+ ) {
+ Surface {
+ Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(horizontal = 16.dp)) {
+ Name(deviceData, Modifier.baselineHeight(32.dp))
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ HorizontalDivider()
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ deviceData.phoneNumbers.forEach {
+ PhoneNumber(
+ value = it,
+ modifier = Modifier
+ .baselineHeight(24.dp)
+ .clickable(onClick = { onPhoneNumberClicked(it) }),
+ style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace)
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Name(
+ deviceData: DeviceScreenState,
+ modifier: Modifier
+) {
+ Text(
+ text = deviceData.name,
+ modifier = modifier,
+ style = MaterialTheme.typography.headlineSmall
+ )
+}
+
+@Composable
+private fun PhoneNumber(
+ value: String,
+ modifier: Modifier,
+ style: TextStyle
+) {
+ Text(
+ text = value,
+ modifier = modifier,
+ style = style
+ )
+}
+
+@Composable
+fun DeviceError() {
+ Text(stringResource(R.string.device_error))
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceFragment.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceFragment.kt
new file mode 100644
index 0000000..d1a6c5c
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceFragment.kt
@@ -0,0 +1,87 @@
+package xyz.magicalbits.smsremote.device
+
+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.runtime.rememberCoroutineScope
+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 xyz.magicalbits.smsremote.MainViewModel
+import xyz.magicalbits.smsremote.R
+import xyz.magicalbits.smsremote.components.JetchatAppBar
+import xyz.magicalbits.smsremote.theme.JetchatTheme
+import kotlin.getValue
+import androidx.navigation.findNavController
+import kotlinx.coroutines.launch
+
+class DeviceFragment : Fragment() {
+ private val viewModel: DeviceViewModel by viewModels()
+ private val activityViewModel: MainViewModel by activityViewModels()
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ // Consider using safe args plugin
+ val deviceName = arguments?.getString("deviceName")
+ viewModel.setDeviceId(deviceName)
+ }
+
+ @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 deviceData by viewModel.deviceData.observeAsState()
+ val nestedScrollInteropConnection = rememberNestedScrollInteropConnection()
+
+ JetchatTheme {
+ if (deviceData == null) {
+ DeviceError()
+ } else {
+ val navController: NavController = rootView.findNavController()
+ DeviceScreen(
+ deviceData = deviceData!!,
+ nestedScrollInteropConnection = nestedScrollInteropConnection,
+ onPhoneNumberClicked = {
+ val args = Bundle(1)
+ args.putString("phoneNumber", it)
+ navController.navigate(R.id.action_device_to_conversation, args)
+ },
+ )
+ }
+ }
+ }
+ }
+
+ return rootView
+ }
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceViewModel.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceViewModel.kt
new file mode 100644
index 0000000..179cada
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceViewModel.kt
@@ -0,0 +1,35 @@
+package xyz.magicalbits.smsremote.device
+
+import androidx.compose.runtime.Immutable
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import xyz.magicalbits.smsremote.data.a14Device
+import xyz.magicalbits.smsremote.data.iPhoneDevice
+
+class DeviceViewModel : ViewModel() {
+ private var deviceId: String = ""
+ private val _deviceData = MutableLiveData()
+ val deviceData: LiveData = _deviceData
+
+ fun setDeviceId(newDeviceId: String?) {
+ if (newDeviceId != null) {
+ deviceId = newDeviceId
+ }
+
+ // placeholder since there's no API reading logic yet
+ _deviceData.value =
+ if (deviceId == "Samsung A14") {
+ a14Device
+ } else {
+ iPhoneDevice
+ }
+ }
+}
+
+@Immutable
+data class DeviceScreenState(
+ val deviceId: String,
+ val name: String,
+ val phoneNumbers: List
+)
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/Previews.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/Previews.kt
new file mode 100644
index 0000000..c1201f8
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/Previews.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.profile
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import xyz.magicalbits.smsremote.data.colleagueProfile
+import xyz.magicalbits.smsremote.data.meProfile
+import xyz.magicalbits.smsremote.theme.JetchatTheme
+
+@Preview(widthDp = 340, name = "340 width - Me")
+@Composable
+fun ProfilePreview340() {
+ JetchatTheme {
+ ProfileScreen(meProfile)
+ }
+}
+
+@Preview(widthDp = 480, name = "480 width - Me")
+@Composable
+fun ProfilePreview480Me() {
+ JetchatTheme {
+ ProfileScreen(meProfile)
+ }
+}
+
+@Preview(widthDp = 480, name = "480 width - Other")
+@Composable
+fun ProfilePreview480Other() {
+ JetchatTheme {
+ ProfileScreen(colleagueProfile)
+ }
+}
+@Preview(widthDp = 340, name = "340 width - Me - Dark")
+@Composable
+fun ProfilePreview340MeDark() {
+ JetchatTheme(isDarkTheme = true) {
+ ProfileScreen(meProfile)
+ }
+}
+
+@Preview(widthDp = 480, name = "480 width - Me - Dark")
+@Composable
+fun ProfilePreview480MeDark() {
+ JetchatTheme(isDarkTheme = true) {
+ ProfileScreen(meProfile)
+ }
+}
+
+@Preview(widthDp = 480, name = "480 width - Other - Dark")
+@Composable
+fun ProfilePreview480OtherDark() {
+ JetchatTheme(isDarkTheme = true) {
+ ProfileScreen(colleagueProfile)
+ }
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/Profile.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/Profile.kt
new file mode 100644
index 0000000..1ac9d3a
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/Profile.kt
@@ -0,0 +1,294 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.profile
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.layout.BoxWithConstraints
+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.heightIn
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
+import xyz.magicalbits.smsremote.R
+import xyz.magicalbits.smsremote.components.AnimatingFabContent
+import xyz.magicalbits.smsremote.components.baselineHeight
+import xyz.magicalbits.smsremote.data.colleagueProfile
+import xyz.magicalbits.smsremote.data.meProfile
+import xyz.magicalbits.smsremote.theme.JetchatTheme
+
+//@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
+@Composable
+fun ProfileScreen(
+ userData: ProfileScreenState,
+ nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
+) {
+ var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
+ if (functionalityNotAvailablePopupShown) {
+ FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
+ }
+
+ val scrollState = rememberScrollState()
+
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(nestedScrollInteropConnection)
+ .systemBarsPadding(),
+ ) {
+ Surface {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState),
+ ) {
+ ProfileHeader(
+ scrollState,
+ userData,
+ this@BoxWithConstraints.maxHeight,
+ )
+ UserInfoFields(userData, this@BoxWithConstraints.maxHeight)
+ }
+ }
+
+ val fabExtended by remember { derivedStateOf { scrollState.value == 0 } }
+ ProfileFab(
+ extended = fabExtended,
+ userIsMe = userData.isMe(),
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ // Offsets the FAB to compensate for CoordinatorLayout collapsing behaviour
+ .offset(y = ((-100).dp)),
+ onFabClicked = { functionalityNotAvailablePopupShown = true },
+ )
+ }
+}
+
+@Composable
+private fun UserInfoFields(userData: ProfileScreenState, containerHeight: Dp) {
+ Column {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ NameAndPosition(userData)
+
+ ProfileProperty(stringResource(R.string.display_name), userData.displayName)
+
+ ProfileProperty(stringResource(R.string.status), userData.status)
+
+ ProfileProperty(stringResource(R.string.twitter), userData.twitter, isLink = true)
+
+ userData.timeZone?.let {
+ ProfileProperty(stringResource(R.string.timezone), userData.timeZone)
+ }
+
+ // Add a spacer that always shows part (320.dp) of the fields list regardless of the device,
+ // in order to always leave some content at the top.
+ Spacer(Modifier.height((containerHeight - 320.dp).coerceAtLeast(0.dp)))
+ }
+}
+
+@Composable
+private fun NameAndPosition(userData: ProfileScreenState) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ Name(
+ userData,
+ modifier = Modifier.baselineHeight(32.dp),
+ )
+ Position(
+ userData,
+ modifier = Modifier
+ .padding(bottom = 20.dp)
+ .baselineHeight(24.dp),
+ )
+ }
+}
+
+@Composable
+private fun Name(userData: ProfileScreenState, modifier: Modifier = Modifier) {
+ Text(
+ text = userData.name,
+ modifier = modifier,
+ style = MaterialTheme.typography.headlineSmall,
+ )
+}
+
+@Composable
+private fun Position(userData: ProfileScreenState, modifier: Modifier = Modifier) {
+ Text(
+ text = userData.position,
+ modifier = modifier,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+}
+
+@Composable
+private fun ProfileHeader(scrollState: ScrollState, data: ProfileScreenState, containerHeight: Dp) {
+ val offset = (scrollState.value / 2)
+ val offsetDp = with(LocalDensity.current) { offset.toDp() }
+
+ data.photo?.let {
+ Image(
+ modifier = Modifier
+ .heightIn(max = containerHeight / 2)
+ .fillMaxWidth()
+ // TODO: Update to use offset to avoid recomposition
+ .padding(
+ start = 16.dp,
+ top = offsetDp,
+ end = 16.dp,
+ )
+ .clip(CircleShape),
+ painter = painterResource(id = it),
+ contentScale = ContentScale.Crop,
+ contentDescription = null,
+ )
+ }
+}
+
+@Composable
+fun ProfileProperty(label: String, value: String, isLink: Boolean = false) {
+ Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) {
+ HorizontalDivider()
+ Text(
+ text = label,
+ modifier = Modifier.baselineHeight(24.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ val style = if (isLink) {
+ MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary)
+ } else {
+ MaterialTheme.typography.bodyLarge
+ }
+ Text(
+ text = value,
+ modifier = Modifier.baselineHeight(24.dp),
+ style = style,
+ )
+ }
+}
+
+@Composable
+fun ProfileError() {
+ Text(stringResource(R.string.profile_error))
+}
+
+@Composable
+fun ProfileFab(extended: Boolean, userIsMe: Boolean, modifier: Modifier = Modifier, onFabClicked: () -> Unit = { }) {
+ key(userIsMe) {
+ // Prevent multiple invocations to execute during composition
+ FloatingActionButton(
+ onClick = onFabClicked,
+ modifier = modifier
+ .padding(16.dp)
+ .navigationBarsPadding()
+ .height(48.dp)
+ .widthIn(min = 48.dp),
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer,
+ ) {
+ AnimatingFabContent(
+ icon = {
+ Icon(
+ painter = painterResource(id = if (userIsMe) R.drawable.ic_create else R.drawable.ic_chat),
+ contentDescription = stringResource(
+ if (userIsMe) R.string.edit_profile else R.string.message,
+ ),
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(
+ id = if (userIsMe) R.string.edit_profile else R.string.message,
+ ),
+ )
+ },
+ extended = extended,
+ )
+ }
+ }
+}
+
+@Preview(widthDp = 640, heightDp = 360)
+@Composable
+fun ConvPreviewLandscapeMeDefault() {
+ JetchatTheme {
+ ProfileScreen(meProfile)
+ }
+}
+
+@Preview(widthDp = 360, heightDp = 480)
+@Composable
+fun ConvPreviewPortraitMeDefault() {
+ JetchatTheme {
+ ProfileScreen(meProfile)
+ }
+}
+
+@Preview(widthDp = 360, heightDp = 480)
+@Composable
+fun ConvPreviewPortraitOtherDefault() {
+ JetchatTheme {
+ ProfileScreen(colleagueProfile)
+ }
+}
+
+@Preview
+@Composable
+fun ProfileFabPreview() {
+ JetchatTheme {
+ ProfileFab(extended = true, userIsMe = false)
+ }
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/ProfileFragment.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/ProfileFragment.kt
new file mode 100644
index 0000000..dcd0aa0
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/ProfileFragment.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.profile
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
+import xyz.magicalbits.smsremote.MainViewModel
+import xyz.magicalbits.smsremote.R
+import xyz.magicalbits.smsremote.components.JetchatAppBar
+import xyz.magicalbits.smsremote.theme.JetchatTheme
+
+class ProfileFragment : Fragment() {
+ private val viewModel: ProfileViewModel by viewModels()
+ private val activityViewModel: MainViewModel by activityViewModels()
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ // Consider using safe args plugin
+ val userId = arguments?.getString("userId")
+ viewModel.setUserId(userId)
+ }
+
+ @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 {
+ var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
+ if (functionalityNotAvailablePopupShown) {
+ FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
+ }
+
+ JetchatTheme {
+ JetchatAppBar(
+ // Reset the minimum bounds that are passed to the root of a compose tree
+ modifier = Modifier.wrapContentSize(),
+ onNavIconPressed = { activityViewModel.openDrawer() },
+ title = { },
+ actions = {
+ // More icon
+ Icon(
+ painter = painterResource(id = R.drawable.ic_more_vert),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier =
+ Modifier
+ .clickable(onClick = {
+ functionalityNotAvailablePopupShown = true
+ })
+ .padding(horizontal = 12.dp, vertical = 16.dp)
+ .height(24.dp),
+ contentDescription = stringResource(id = R.string.more_options),
+ )
+ },
+ )
+ }
+ }
+ }
+
+ rootView.findViewById(R.id.profile_compose_view).apply {
+ setContent {
+ val userData by viewModel.userData.observeAsState()
+ val nestedScrollInteropConnection = rememberNestedScrollInteropConnection()
+
+ JetchatTheme {
+ if (userData == null) {
+ ProfileError()
+ } else {
+ ProfileScreen(
+ userData = userData!!,
+ nestedScrollInteropConnection = nestedScrollInteropConnection,
+ )
+ }
+ }
+ }
+ }
+ return rootView
+ }
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/ProfileViewModel.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/ProfileViewModel.kt
new file mode 100644
index 0000000..27c8864
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/ProfileViewModel.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.profile
+
+import androidx.annotation.DrawableRes
+import androidx.compose.runtime.Immutable
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import xyz.magicalbits.smsremote.data.colleagueProfile
+import xyz.magicalbits.smsremote.data.meProfile
+
+class ProfileViewModel : ViewModel() {
+ private var userId: String = ""
+
+ fun setUserId(newUserId: String?) {
+ if (newUserId != userId) {
+ userId = newUserId ?: meProfile.userId
+ }
+ // Workaround for simplicity
+ _userData.value =
+ if (userId == meProfile.userId || userId == meProfile.displayName) {
+ meProfile
+ } else {
+ colleagueProfile
+ }
+ }
+
+ private val _userData = MutableLiveData()
+ val userData: LiveData = _userData
+}
+
+@Immutable
+data class ProfileScreenState(
+ val userId: String,
+ @param:DrawableRes val photo: Int?,
+ val name: String,
+ val status: String,
+ val displayName: String,
+ val position: String,
+ val twitter: String = "",
+ val timeZone: String?, // Null if me
+ val commonChannels: String?, // Null if me
+) {
+ fun isMe() = userId == meProfile.userId
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Color.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Color.kt
new file mode 100644
index 0000000..0c7f33a
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Color.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Blue10 = Color(0xFF000F5E)
+val Blue20 = Color(0xFF001E92)
+val Blue30 = Color(0xFF002ECC)
+val Blue40 = Color(0xFF1546F6)
+val Blue80 = Color(0xFFB8C3FF)
+val Blue90 = Color(0xFFDDE1FF)
+
+val DarkBlue10 = Color(0xFF00036B)
+val DarkBlue20 = Color(0xFF000BA6)
+val DarkBlue30 = Color(0xFF1026D3)
+val DarkBlue40 = Color(0xFF3648EA)
+val DarkBlue80 = Color(0xFFBBC2FF)
+val DarkBlue90 = Color(0xFFDEE0FF)
+
+val Yellow10 = Color(0xFF261900)
+val Yellow20 = Color(0xFF402D00)
+val Yellow30 = Color(0xFF5C4200)
+val Yellow40 = Color(0xFF7A5900)
+val Yellow80 = Color(0xFFFABD1B)
+val Yellow90 = Color(0xFFFFDE9C)
+
+val Red10 = Color(0xFF410001)
+val Red20 = Color(0xFF680003)
+val Red30 = Color(0xFF930006)
+val Red40 = Color(0xFFBA1B1B)
+val Red80 = Color(0xFFFFB4A9)
+val Red90 = Color(0xFFFFDAD4)
+
+val Grey10 = Color(0xFF191C1D)
+val Grey20 = Color(0xFF2D3132)
+val Grey80 = Color(0xFFC4C7C7)
+val Grey90 = Color(0xFFE0E3E3)
+val Grey95 = Color(0xFFEFF1F1)
+val Grey99 = Color(0xFFFBFDFD)
+
+val BlueGrey30 = Color(0xFF45464F)
+val BlueGrey50 = Color(0xFF767680)
+val BlueGrey60 = Color(0xFF90909A)
+val BlueGrey80 = Color(0xFFC6C5D0)
+val BlueGrey90 = Color(0xFFE2E1EC)
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Themes.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Themes.kt
new file mode 100644
index 0000000..afc47e5
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Themes.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.theme
+
+import android.annotation.SuppressLint
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+
+val JetchatDarkColorScheme = darkColorScheme(
+ primary = Blue80,
+ onPrimary = Blue20,
+ primaryContainer = Blue30,
+ onPrimaryContainer = Blue90,
+ inversePrimary = Blue40,
+ secondary = DarkBlue80,
+ onSecondary = DarkBlue20,
+ secondaryContainer = DarkBlue30,
+ onSecondaryContainer = DarkBlue90,
+ tertiary = Yellow80,
+ onTertiary = Yellow20,
+ tertiaryContainer = Yellow30,
+ onTertiaryContainer = Yellow90,
+ error = Red80,
+ onError = Red20,
+ errorContainer = Red30,
+ onErrorContainer = Red90,
+ background = Grey10,
+ onBackground = Grey90,
+ surface = Grey10,
+ onSurface = Grey80,
+ inverseSurface = Grey90,
+ inverseOnSurface = Grey20,
+ surfaceVariant = BlueGrey30,
+ onSurfaceVariant = BlueGrey80,
+ outline = BlueGrey60,
+)
+
+val JetchatLightColorScheme = lightColorScheme(
+ primary = Blue40,
+ onPrimary = Color.White,
+ primaryContainer = Blue90,
+ onPrimaryContainer = Blue10,
+ inversePrimary = Blue80,
+ secondary = DarkBlue40,
+ onSecondary = Color.White,
+ secondaryContainer = DarkBlue90,
+ onSecondaryContainer = DarkBlue10,
+ tertiary = Yellow40,
+ onTertiary = Color.White,
+ tertiaryContainer = Yellow90,
+ onTertiaryContainer = Yellow10,
+ error = Red40,
+ onError = Color.White,
+ errorContainer = Red90,
+ onErrorContainer = Red10,
+ background = Grey99,
+ onBackground = Grey10,
+ surface = Grey99,
+ onSurface = Grey10,
+ inverseSurface = Grey20,
+ inverseOnSurface = Grey95,
+ surfaceVariant = BlueGrey90,
+ onSurfaceVariant = BlueGrey30,
+ outline = BlueGrey50,
+)
+
+@SuppressLint("NewApi")
+@Composable
+fun JetchatTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), isDynamicColor: Boolean = true, content: @Composable () -> Unit) {
+ val dynamicColor = isDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ val myColorScheme = when {
+ dynamicColor && isDarkTheme -> {
+ dynamicDarkColorScheme(LocalContext.current)
+ }
+
+ dynamicColor && !isDarkTheme -> {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+
+ isDarkTheme -> JetchatDarkColorScheme
+
+ else -> JetchatLightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = myColorScheme,
+ typography = JetchatTypography,
+ content = content,
+ )
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Typography.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Typography.kt
new file mode 100644
index 0000000..201a1c7
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Typography.kt
@@ -0,0 +1,182 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.googlefonts.Font
+import androidx.compose.ui.text.googlefonts.GoogleFont
+import androidx.compose.ui.unit.sp
+import xyz.magicalbits.smsremote.R
+
+val provider =
+ GoogleFont.Provider(
+ providerAuthority = "com.google.android.gms.fonts",
+ providerPackage = "com.google.android.gms",
+ certificates = R.array.com_google_android_gms_fonts_certs,
+ )
+
+val MontserratFont = GoogleFont(name = "Montserrat")
+
+val KarlaFont = GoogleFont(name = "Karla")
+
+val MontserratFontFamily =
+ FontFamily(
+ Font(googleFont = MontserratFont, fontProvider = provider),
+ Font(resId = R.font.montserrat_regular),
+ Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.Light),
+ Font(resId = R.font.montserrat_light, weight = FontWeight.Light),
+ Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.Medium),
+ Font(resId = R.font.montserrat_medium, weight = FontWeight.Medium),
+ Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.SemiBold),
+ Font(resId = R.font.montserrat_semibold, weight = FontWeight.SemiBold),
+ )
+
+val KarlaFontFamily =
+ FontFamily(
+ Font(googleFont = KarlaFont, fontProvider = provider),
+ Font(resId = R.font.karla_regular),
+ Font(googleFont = KarlaFont, fontProvider = provider, weight = FontWeight.Bold),
+ Font(resId = R.font.karla_bold, weight = FontWeight.Bold),
+ )
+
+val JetchatTypography =
+ Typography(
+ displayLarge =
+ TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontWeight = FontWeight.Light,
+ fontSize = 57.sp,
+ lineHeight = 64.sp,
+ letterSpacing = 0.sp,
+ ),
+ displayMedium =
+ TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontWeight = FontWeight.Light,
+ fontSize = 45.sp,
+ lineHeight = 52.sp,
+ letterSpacing = 0.sp,
+ ),
+ displaySmall =
+ TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontWeight = FontWeight.Normal,
+ fontSize = 36.sp,
+ lineHeight = 44.sp,
+ letterSpacing = 0.sp,
+ ),
+ headlineLarge =
+ TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ letterSpacing = 0.sp,
+ ),
+ headlineMedium =
+ TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 28.sp,
+ lineHeight = 36.sp,
+ letterSpacing = 0.sp,
+ ),
+ headlineSmall =
+ TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 24.sp,
+ lineHeight = 32.sp,
+ letterSpacing = 0.sp,
+ ),
+ titleLarge =
+ TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp,
+ ),
+ titleMedium =
+ TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.15.sp,
+ ),
+ titleSmall =
+ TextStyle(
+ fontFamily = KarlaFontFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ bodyLarge =
+ TextStyle(
+ fontFamily = KarlaFontFamily,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.15.sp,
+ ),
+ bodyMedium =
+ TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.25.sp,
+ ),
+ bodySmall =
+ TextStyle(
+ fontFamily = KarlaFontFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.4.sp,
+ ),
+ labelLarge =
+ TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ labelMedium =
+ TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ labelSmall =
+ TextStyle(
+ fontFamily = MontserratFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ )
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/JetChatWidget.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/JetChatWidget.kt
new file mode 100644
index 0000000..081fe63
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/JetChatWidget.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.widget
+
+import android.content.Context
+import androidx.glance.GlanceId
+import androidx.glance.GlanceTheme
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.provideContent
+import xyz.magicalbits.smsremote.data.unreadMessages
+import xyz.magicalbits.smsremote.widget.composables.MessagesWidget
+
+class JetChatWidget : GlanceAppWidget() {
+ override suspend fun provideGlance(
+ context: Context,
+ id: GlanceId,
+ ) {
+ provideContent {
+ GlanceTheme {
+ MessagesWidget(unreadMessages.toList())
+ }
+ }
+ }
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/WidgetReceiver.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/WidgetReceiver.kt
new file mode 100644
index 0000000..8f92241
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/WidgetReceiver.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.widget
+
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+
+class WidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget
+ get() = JetChatWidget()
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/composables/MessagesWidget.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/composables/MessagesWidget.kt
new file mode 100644
index 0000000..2b6a80d
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/composables/MessagesWidget.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.widget.composables
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.glance.GlanceModifier
+import androidx.glance.ImageProvider
+import androidx.glance.LocalContext
+import androidx.glance.action.actionStartActivity
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.components.Scaffold
+import androidx.glance.appwidget.components.TitleBar
+import androidx.glance.appwidget.lazy.LazyColumn
+import androidx.glance.layout.Column
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.text.Text
+import xyz.magicalbits.smsremote.NavActivity
+import xyz.magicalbits.smsremote.R
+import xyz.magicalbits.smsremote.conversation.Message
+import xyz.magicalbits.smsremote.widget.theme.JetChatGlanceTextStyles
+import xyz.magicalbits.smsremote.widget.theme.JetchatGlanceColorScheme
+
+@Composable
+fun MessagesWidget(messages: List) {
+ Scaffold(titleBar = {
+ TitleBar(
+ startIcon = ImageProvider(R.drawable.ic_jetchat),
+ iconColor = null,
+ title = LocalContext.current.getString(R.string.messages_widget_title),
+ )
+ }, backgroundColor = JetchatGlanceColorScheme.colors.background) {
+ LazyColumn(modifier = GlanceModifier.fillMaxWidth()) {
+ messages.forEach {
+ item {
+ Column(modifier = GlanceModifier.fillMaxWidth()) {
+ MessageItem(it)
+ Spacer(modifier = GlanceModifier.height(10.dp))
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MessageItem(message: Message) {
+ Column(modifier = GlanceModifier.clickable(actionStartActivity()).fillMaxWidth()) {
+ Text(
+ text = message.author,
+ style = JetChatGlanceTextStyles.titleMedium,
+ )
+ Text(
+ text = message.content,
+ style = JetChatGlanceTextStyles.bodyMedium,
+ )
+ }
+}
+
+@Preview
+@Composable
+fun MessageItemPreview() {
+ MessageItem(Message("John", "This is a preview of the message Item", "8:02PM"))
+}
+
+@Preview
+@Composable
+fun WidgetPreview() {
+ MessagesWidget(listOf(Message("John", "This is a preview of the message Item", "8:02PM")))
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/theme/Theme.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/theme/Theme.kt
new file mode 100644
index 0000000..bdf2841
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/theme/Theme.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.widget.theme
+
+import androidx.glance.material3.ColorProviders
+import xyz.magicalbits.smsremote.theme.JetchatDarkColorScheme
+import xyz.magicalbits.smsremote.theme.JetchatLightColorScheme
+
+object JetchatGlanceColorScheme {
+ val colors = ColorProviders(
+ light = JetchatLightColorScheme,
+ dark = JetchatDarkColorScheme,
+ )
+}
diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/theme/Type.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/theme/Type.kt
new file mode 100644
index 0000000..cd2d124
--- /dev/null
+++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/theme/Type.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package xyz.magicalbits.smsremote.widget.theme
+
+import androidx.compose.ui.unit.sp
+import androidx.glance.text.FontWeight
+import androidx.glance.text.TextStyle
+
+object JetChatGlanceTextStyles {
+
+ val titleMedium = TextStyle(
+ fontSize = 16.sp,
+ color = JetchatGlanceColorScheme.colors.onSurfaceVariant,
+ fontWeight = FontWeight.Bold,
+ )
+ val bodyMedium = TextStyle(
+ fontSize = 16.sp,
+ color = JetchatGlanceColorScheme.colors.onSurfaceVariant,
+ fontWeight = FontWeight.Normal,
+ )
+}
diff --git a/android/src/main/res/drawable-nodpi/ali.png b/android/src/main/res/drawable-nodpi/ali.png
new file mode 100644
index 0000000..eab3c65
Binary files /dev/null and b/android/src/main/res/drawable-nodpi/ali.png differ
diff --git a/android/src/main/res/drawable-nodpi/someone_else.jpg b/android/src/main/res/drawable-nodpi/someone_else.jpg
new file mode 100644
index 0000000..95bc4a8
Binary files /dev/null and b/android/src/main/res/drawable-nodpi/someone_else.jpg differ
diff --git a/android/src/main/res/drawable-nodpi/sticker.png b/android/src/main/res/drawable-nodpi/sticker.png
new file mode 100644
index 0000000..54bbcfb
Binary files /dev/null and b/android/src/main/res/drawable-nodpi/sticker.png differ
diff --git a/android/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000..6598a88
--- /dev/null
+++ b/android/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/res/drawable/ic_alternate_email.xml b/android/src/main/res/drawable/ic_alternate_email.xml
new file mode 100644
index 0000000..37d1268
--- /dev/null
+++ b/android/src/main/res/drawable/ic_alternate_email.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/src/main/res/drawable/ic_arrow_downward.xml b/android/src/main/res/drawable/ic_arrow_downward.xml
new file mode 100644
index 0000000..66f1ae3
--- /dev/null
+++ b/android/src/main/res/drawable/ic_arrow_downward.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/src/main/res/drawable/ic_baseline_person_24.xml b/android/src/main/res/drawable/ic_baseline_person_24.xml
new file mode 100644
index 0000000..53899e9
--- /dev/null
+++ b/android/src/main/res/drawable/ic_baseline_person_24.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/android/src/main/res/drawable/ic_chat.xml b/android/src/main/res/drawable/ic_chat.xml
new file mode 100644
index 0000000..e53bb33
--- /dev/null
+++ b/android/src/main/res/drawable/ic_chat.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/src/main/res/drawable/ic_create.xml b/android/src/main/res/drawable/ic_create.xml
new file mode 100644
index 0000000..db9b31f
--- /dev/null
+++ b/android/src/main/res/drawable/ic_create.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/src/main/res/drawable/ic_duo.xml b/android/src/main/res/drawable/ic_duo.xml
new file mode 100644
index 0000000..7e0cdcd
--- /dev/null
+++ b/android/src/main/res/drawable/ic_duo.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/src/main/res/drawable/ic_info.xml b/android/src/main/res/drawable/ic_info.xml
new file mode 100644
index 0000000..1ece034
--- /dev/null
+++ b/android/src/main/res/drawable/ic_info.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/src/main/res/drawable/ic_insert_photo.xml b/android/src/main/res/drawable/ic_insert_photo.xml
new file mode 100644
index 0000000..083151a
--- /dev/null
+++ b/android/src/main/res/drawable/ic_insert_photo.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/src/main/res/drawable/ic_jetchat.xml b/android/src/main/res/drawable/ic_jetchat.xml
new file mode 100644
index 0000000..1f33c7f
--- /dev/null
+++ b/android/src/main/res/drawable/ic_jetchat.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/res/drawable/ic_jetchat_back.xml b/android/src/main/res/drawable/ic_jetchat_back.xml
new file mode 100644
index 0000000..b238144
--- /dev/null
+++ b/android/src/main/res/drawable/ic_jetchat_back.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/android/src/main/res/drawable/ic_jetchat_front.xml b/android/src/main/res/drawable/ic_jetchat_front.xml
new file mode 100644
index 0000000..5946519
--- /dev/null
+++ b/android/src/main/res/drawable/ic_jetchat_front.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/android/src/main/res/drawable/ic_launcher_foreground.xml b/android/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..a370dbd
--- /dev/null
+++ b/android/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/res/drawable/ic_launcher_monochrome.xml b/android/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 0000000..9a28510
--- /dev/null
+++ b/android/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/res/drawable/ic_mic.xml b/android/src/main/res/drawable/ic_mic.xml
new file mode 100644
index 0000000..ed523bb
--- /dev/null
+++ b/android/src/main/res/drawable/ic_mic.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/android/src/main/res/drawable/ic_mood.xml b/android/src/main/res/drawable/ic_mood.xml
new file mode 100644
index 0000000..b5f4002
--- /dev/null
+++ b/android/src/main/res/drawable/ic_mood.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/android/src/main/res/drawable/ic_more_vert.xml b/android/src/main/res/drawable/ic_more_vert.xml
new file mode 100644
index 0000000..59400ec
--- /dev/null
+++ b/android/src/main/res/drawable/ic_more_vert.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/src/main/res/drawable/ic_place.xml b/android/src/main/res/drawable/ic_place.xml
new file mode 100644
index 0000000..ddb5a94
--- /dev/null
+++ b/android/src/main/res/drawable/ic_place.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/src/main/res/drawable/ic_search.xml b/android/src/main/res/drawable/ic_search.xml
new file mode 100644
index 0000000..20c7b4e
--- /dev/null
+++ b/android/src/main/res/drawable/ic_search.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/src/main/res/drawable/jetchat_logo.xml b/android/src/main/res/drawable/jetchat_logo.xml
new file mode 100644
index 0000000..20e7035
--- /dev/null
+++ b/android/src/main/res/drawable/jetchat_logo.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/android/src/main/res/drawable/widget_icon.png b/android/src/main/res/drawable/widget_icon.png
new file mode 100644
index 0000000..70d386e
Binary files /dev/null and b/android/src/main/res/drawable/widget_icon.png differ
diff --git a/android/src/main/res/font/karla_bold.ttf b/android/src/main/res/font/karla_bold.ttf
new file mode 100644
index 0000000..052231c
Binary files /dev/null and b/android/src/main/res/font/karla_bold.ttf differ
diff --git a/android/src/main/res/font/karla_regular.ttf b/android/src/main/res/font/karla_regular.ttf
new file mode 100644
index 0000000..4269aa0
Binary files /dev/null and b/android/src/main/res/font/karla_regular.ttf differ
diff --git a/android/src/main/res/font/montserrat_light.ttf b/android/src/main/res/font/montserrat_light.ttf
new file mode 100644
index 0000000..990857d
Binary files /dev/null and b/android/src/main/res/font/montserrat_light.ttf differ
diff --git a/android/src/main/res/font/montserrat_medium.ttf b/android/src/main/res/font/montserrat_medium.ttf
new file mode 100755
index 0000000..6e079f6
Binary files /dev/null and b/android/src/main/res/font/montserrat_medium.ttf differ
diff --git a/android/src/main/res/font/montserrat_regular.ttf b/android/src/main/res/font/montserrat_regular.ttf
new file mode 100755
index 0000000..8d443d5
Binary files /dev/null and b/android/src/main/res/font/montserrat_regular.ttf differ
diff --git a/android/src/main/res/font/montserrat_semibold.ttf b/android/src/main/res/font/montserrat_semibold.ttf
new file mode 100755
index 0000000..f8a43f2
Binary files /dev/null and b/android/src/main/res/font/montserrat_semibold.ttf differ
diff --git a/android/src/main/res/layout/content_main.xml b/android/src/main/res/layout/content_main.xml
new file mode 100644
index 0000000..f185959
--- /dev/null
+++ b/android/src/main/res/layout/content_main.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
diff --git a/android/src/main/res/layout/fragment_profile.xml b/android/src/main/res/layout/fragment_profile.xml
new file mode 100644
index 0000000..3cdf5ea
--- /dev/null
+++ b/android/src/main/res/layout/fragment_profile.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/src/main/res/menu/activity_main_drawer.xml b/android/src/main/res/menu/activity_main_drawer.xml
new file mode 100644
index 0000000..aaae5af
--- /dev/null
+++ b/android/src/main/res/menu/activity_main_drawer.xml
@@ -0,0 +1,28 @@
+
+
+
+
diff --git a/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..c78bee3
--- /dev/null
+++ b/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..6526af6
Binary files /dev/null and b/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/src/main/res/navigation/mobile_navigation.xml b/android/src/main/res/navigation/mobile_navigation.xml
new file mode 100644
index 0000000..84b650c
--- /dev/null
+++ b/android/src/main/res/navigation/mobile_navigation.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/res/values-night/colors.xml b/android/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..afcea2b
--- /dev/null
+++ b/android/src/main/res/values-night/colors.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+ @color/yellow400
+ @color/blue300
+
diff --git a/android/src/main/res/values-night/themes.xml b/android/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..be7da11
--- /dev/null
+++ b/android/src/main/res/values-night/themes.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/android/src/main/res/values-v23/font_certs.xml b/android/src/main/res/values-v23/font_certs.xml
new file mode 100644
index 0000000..6dec447
--- /dev/null
+++ b/android/src/main/res/values-v23/font_certs.xml
@@ -0,0 +1,32 @@
+
+
+
+
+ - @array/com_google_android_gms_fonts_certs_dev
+ - @array/com_google_android_gms_fonts_certs_prod
+
+
+ -
+ MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=
+
+
+
+ -
+ MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK
+
+
+
diff --git a/android/src/main/res/values-v23/themes.xml b/android/src/main/res/values-v23/themes.xml
new file mode 100644
index 0000000..4af8a86
--- /dev/null
+++ b/android/src/main/res/values-v23/themes.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
diff --git a/android/src/main/res/values-v27/themes.xml b/android/src/main/res/values-v27/themes.xml
new file mode 100644
index 0000000..4fcb700
--- /dev/null
+++ b/android/src/main/res/values-v27/themes.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/android/src/main/res/values/colors.xml b/android/src/main/res/values/colors.xml
new file mode 100644
index 0000000..d4a326c
--- /dev/null
+++ b/android/src/main/res/values/colors.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+ #0540F2
+ #001CCF
+ #F3B711
+
+ #6F7EF9
+ #4860F7
+ #F6E547
+
+
+ @color/yellow700
+ @color/blue500
+
+
+ #4D000000
+
+
diff --git a/android/src/main/res/values/dimens.xml b/android/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..a15764b
--- /dev/null
+++ b/android/src/main/res/values/dimens.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ 16dp
+ 16dp
+ 16dp
+ 8dp
+ 24dp
+ 16dp
+
diff --git a/android/src/main/res/values/ic_launcher_background.xml b/android/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..83e59df
--- /dev/null
+++ b/android/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ #0540F2
+
\ No newline at end of file
diff --git a/android/src/main/res/values/ids.xml b/android/src/main/res/values/ids.xml
new file mode 100644
index 0000000..3f5fd04
--- /dev/null
+++ b/android/src/main/res/values/ids.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
new file mode 100644
index 0000000..d4a0159
--- /dev/null
+++ b/android/src/main/res/values/strings.xml
@@ -0,0 +1,76 @@
+
+
+
+ SMS Remote
+
+ Open navigation drawer
+ Close navigation drawer
+ Composer
+ android.studio@android.com
+ Navigation header
+ Settings
+
+ Home
+ Gallery
+ Slideshow
+ Conversations
+ Profile
+ Jump to bottom
+ Send
+ me
+ 8:30 PM
+ %d members
+ Message #composers
+ ◀ Swipe to cancel
+ Emojis
+ Stickers
+
+ Message
+ Edit Profile
+ There was an error loading the profile
+ Bio
+ Display name
+ Status
+ Timezone
+ Twitter
+ Channels in common
+ Lorem or Ipsum
+
+
+ There was an error loading the device
+
+
+
+ Emoji selector
+ Show Emoji selector
+ Direct Message
+ Attach Photo
+ Location selector
+ Start videochat
+ Text input
+ Functionality currently not available
+ Grab a beverage and check back later!
+ Attached image
+ Search
+ Information
+ More options
+ Touch and hold to record
+ Record voice message
+ JetChat unread messages
+ Add Widget to Home Page
+
+
diff --git a/android/src/main/res/values/themes.xml b/android/src/main/res/values/themes.xml
new file mode 100644
index 0000000..255aaaf
--- /dev/null
+++ b/android/src/main/res/values/themes.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/res/xml/widget_unread_messages_info.xml b/android/src/main/res/xml/widget_unread_messages_info.xml
new file mode 100644
index 0000000..69ea543
--- /dev/null
+++ b/android/src/main/res/xml/widget_unread_messages_info.xml
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..cf2656a
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ alias(libs.plugins.gradle.versions)
+ alias(libs.plugins.version.catalog.update)
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.parcelize) apply false
+ alias(libs.plugins.compose) apply false
+ alias(libs.plugins.spotless) apply false
+}
+
+apply("${project.rootDir}/buildscripts/toml-updater-config.gradle")
+
+subprojects {
+ apply(plugin = "com.diffplug.spotless")
+ configure {
+// kotlin {
+// target("**/*.kt")
+// targetExclude("${layout.buildDirectory}/**/*.kt")
+// ktlint()
+// licenseHeaderFile(rootProject.file("spotless/copyright.kt"))
+// }
+// kotlinGradle {
+// target("*.gradle.kts")
+// targetExclude("${layout.buildDirectory}/**/*.kt")
+// ktlint()
+// // Look for the first line that doesn't have a block comment (assumed to be the license)
+// licenseHeaderFile(rootProject.file("spotless/copyright.kt"), "(^(?![\\/ ]\\*).*$)")
+// }
+ }
+}
diff --git a/buildscripts/toml-updater-config.gradle b/buildscripts/toml-updater-config.gradle
new file mode 100644
index 0000000..e13fd40
--- /dev/null
+++ b/buildscripts/toml-updater-config.gradle
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026 MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+versionCatalogUpdate {
+ sortByKey.set(true)
+
+ keep {
+ // keep versions without any library or plugin reference
+ keepUnusedVersions.set(true)
+ }
+}
+
+def isNonStable = { String version ->
+ def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) }
+ def regex = /^[0-9,.v-]+(-r)?$/
+ return !stableKeyword && !(version ==~ regex)
+}
+
+tasks.named("dependencyUpdates").configure {
+ resolutionStrategy {
+ componentSelection {
+ all {
+ if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) {
+ reject('Release candidate')
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..4d3e32f
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,39 @@
+#
+# Copyright 2026 MagicalBits
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m
+
+# Turn on parallel compilation, caching and on-demand configuration
+org.gradle.configureondemand=true
+org.gradle.caching=true
+org.gradle.parallel=true
+
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..d012485
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,13 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac68b3e883c7c91c760/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac68b3e883c7c91c760/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/f257be9f04bfdf169051808541767806/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/1dcbacacca32618bd21ec5465779ade1/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac68b3e883c7c91c760/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/1e91f45234d88a64dafb961c93ddc75a/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect
+toolchainVendor=ADOPTIUM
+toolchainVersion=21
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..f20063f
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,175 @@
+#####
+# This file is duplicated to individual samples from the global scripts/libs.versions.toml
+# Do not add a dependency to an individual sample, edit the global version instead.
+#####
+[versions]
+accompanist = "0.37.3"
+android-material3 = "1.14.0-rc01"
+androidGradlePlugin = "9.2.0"
+androidx-activity-compose = "1.13.0"
+androidx-appcompat = "1.7.1"
+androidx-compose-bom = "2026.04.01"
+androidx-constraintlayout = "1.1.1"
+androidx-core-splashscreen = "1.2.0"
+androidx-corektx = "1.18.0"
+androidx-glance = "1.1.1"
+androidx-lifecycle = "2.8.2"
+androidx-lifecycle-compose = "2.10.0"
+androidx-lifecycle-runtime-compose = "2.10.0"
+androidx-navigation = "2.9.8"
+androidx-palette = "1.0.0"
+androidx-test = "1.7.0"
+androidx-test-espresso = "3.7.0"
+androidx-test-ext-junit = "1.3.0"
+androidx-test-ext-truth = "1.7.0"
+androidx-tv-foundation = "1.0.0-rc01"
+androidx-tv-material = "1.0.1"
+androidx-wear-compose = "1.6.1"
+androidx-window = "1.5.1"
+androidxHiltNavigationCompose = "1.3.0"
+androix-test-uiautomator = "2.3.0"
+coil = "2.7.0"
+# @keep
+compileSdk = "36"
+coroutines = "1.10.2"
+google-maps = "20.0.0"
+gradle-versions = "0.54.0"
+hilt = "2.59.2"
+hiltExt = "1.3.0"
+horologist = "0.7.15"
+jdkDesugar = "2.1.5"
+junit = "4.13.2"
+kotlin = "2.3.21"
+kotlinx-serialization-json = "1.11.0"
+kotlinx_immutable = "0.4.0"
+ksp = "2.3.7"
+maps-compose = "8.3.0"
+# @keep
+minSdk = "23"
+okhttp = "5.3.2"
+play-services-wearable = "20.0.1"
+robolectric = "4.16.1"
+roborazzi = "1.60.0"
+rome = "2.1.0"
+room = "2.8.4"
+secrets = "2.0.1"
+spotless = "8.4.0"
+# @keep
+targetSdk = "33"
+version-catalog-update = "1.1.0"
+
+[libraries]
+accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" }
+accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
+android-material3 = { module = "com.google.android.material:material", version.ref = "android-material3" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
+androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
+androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
+androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" }
+androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" }
+androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" }
+androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
+androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" }
+androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" }
+androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" }
+androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" }
+androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" }
+androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" }
+androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
+androidx-compose-ui = { module = "androidx.compose.ui:ui" }
+androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" }
+androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
+androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" }
+androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
+androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
+androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" }
+androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
+androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
+androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" }
+androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" }
+androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout" }
+androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" }
+androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" }
+androidx-glance = { module = "androidx.glance:glance", version.ref = "androidx-glance" }
+androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" }
+androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" }
+androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
+androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" }
+androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-compose" }
+androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" }
+androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" }
+androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" }
+androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" }
+androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
+androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
+androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" }
+androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" }
+androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" }
+androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
+androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
+androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
+androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" }
+androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" }
+androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" }
+androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-ext-truth" }
+androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" }
+androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" }
+androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androix-test-uiautomator" }
+androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv-foundation" }
+androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx-tv-material" }
+androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "androidx-wear-compose" }
+androidx-wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "androidx-wear-compose" }
+androidx-wear-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "androidx-wear-compose" }
+androidx-wear-compose-ui-tooling = { module = "androidx.wear.compose:compose-ui-tooling", version.ref = "androidx-wear-compose" }
+androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" }
+androidx-window-core = { module = "androidx.window:window-core", version.ref = "androidx-window" }
+coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
+core-jdk-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "jdkDesugar" }
+dagger-hiltandroidplugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" }
+googlemaps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" }
+googlemaps-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "google-maps" }
+hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
+hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
+hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
+hilt-ext-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltExt" }
+horologist-audio-ui = { module = "com.google.android.horologist:horologist-audio-ui", version.ref = "horologist" }
+horologist-composables = { module = "com.google.android.horologist:horologist-composables", version.ref = "horologist" }
+horologist-compose-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" }
+horologist-compose-material = { module = "com.google.android.horologist:horologist-compose-material", version.ref = "horologist" }
+horologist-compose-tools = { module = "com.google.android.horologist:horologist-compose-tools", version.ref = "horologist" }
+horologist-images-coil = { module = "com.google.android.horologist:horologist-images-coil", version.ref = "horologist" }
+horologist-media-data = { module = "com.google.android.horologist:horologist-media-data", version.ref = "horologist" }
+horologist-media-ui = { module = "com.google.android.horologist:horologist-media-ui", version.ref = "horologist" }
+horologist-roboscreenshots = { module = "com.google.android.horologist:horologist-roboscreenshots", version.ref = "horologist" }
+junit = { module = "junit:junit", version.ref = "junit" }
+kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" }
+kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx_immutable" }
+kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
+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" }
+robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
+roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
+roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" }
+roborazzi-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" }
+rometools-modules = { module = "com.rometools:rome-modules", version.ref = "rome" }
+rometools-rome = { module = "com.rometools:rome", version.ref = "rome" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
+android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
+android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" }
+compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" }
+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
+kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
+secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
+spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
+version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..b1b8ef5
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..b52fb7e
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..b9bb139
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..aa5f10b
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,82 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..dda0c06
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "SMS Remote"
+include(":android")
\ No newline at end of file
diff --git a/spotless/copyright.kt b/spotless/copyright.kt
new file mode 100644
index 0000000..f91856d
--- /dev/null
+++ b/spotless/copyright.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright $YEAR MagicalBits
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+