4 Commits

100 changed files with 6342 additions and 1 deletions
+4
View File
@@ -14,4 +14,8 @@ log/
*.db
# Gradle
.gradle/
local.properties
# IDEA
.idea/
+137
View File
@@ -0,0 +1,137 @@
/*
* 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)
// necessary for using T.serializer() on a data class
alias(libs.plugins.kotlin.serialization)
}
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.kotlinx.serialization.json)
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)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
debugImplementation(libs.androidx.compose.ui.test.manifest)
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)
}
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.SEND_SMS"/>
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.WRITE_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.RECEIVE_MMS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.provider.Telephony.SMS_RECEIVED" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Jetchat.NoActionBar">
<activity
android:name=".NavActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver android:name=".widget.WidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_unread_messages_info" />
</receiver>
</application>
<uses-feature
android:name="android.hardware.telephony"
android:required="true" />
</manifest>
@@ -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
}
}
@@ -0,0 +1,124 @@
/*
* 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.view.ViewCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import xyz.magicalbits.smsremote.components.JetchatDrawer
import xyz.magicalbits.smsremote.databinding.ContentMainBinding
import xyz.magicalbits.smsremote.network.NetworkClient
/**
* 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 }
var deviceDtoList: List<NetworkClient.DeviceDto> = listOf()
viewModel.viewModelScope.launch {
val networkClient = NetworkClient()
deviceDtoList = networkClient.getDevices()
}
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,
deviceDtoList = deviceDtoList,
onChatClicked = {
findNavController().popBackStack(R.id.nav_device, false)
val args = Bundle(1)
args.putString("deviceId", it.access_key)
args.putString("name", it.name)
args.putString("type", it.type)
findNavController().navigate(R.id.nav_device, args)
scope.launch {
drawerState.close()
}
selectedMenu = it.access_key
},
) {
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
}
}
@@ -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")
}
},
)
}
@@ -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
@@ -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))
@@ -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!") })
}
}
@@ -0,0 +1,268 @@
/*
* 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.network.NetworkClient
import xyz.magicalbits.smsremote.theme.JetchatTheme
import xyz.magicalbits.smsremote.widget.WidgetReceiver
@Composable
fun JetchatDrawerContent(onChatClicked: (NetworkClient.DeviceDto) -> Unit, selectedMenu: String = "", deviceDtoList: List<NetworkClient.DeviceDto> = listOf()) {
// 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")
for (deviceDto in deviceDtoList) {
DeviceItem(deviceDto.name, selectedMenu == deviceDto.access_key) {
onChatClicked(deviceDto)
}
}
// 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
}
@@ -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,
)
}
}
@@ -0,0 +1,56 @@
/*
* 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.network.NetworkClient
import xyz.magicalbits.smsremote.theme.JetchatTheme
@Composable
fun JetchatDrawer(
drawerState: DrawerState = rememberDrawerState(initialValue = Closed),
selectedMenu: String,
deviceDtoList: List<NetworkClient.DeviceDto>,
onChatClicked: (NetworkClient.DeviceDto) -> 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,
deviceDtoList = deviceDtoList,
)
}
},
content = content,
)
}
}
@@ -0,0 +1,569 @@
/*
* 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.theme.JetchatTheme
import kotlinx.coroutines.launch
import xyz.magicalbits.smsremote.data.exampleUiStateNew
import xyz.magicalbits.smsremote.network.NetworkClient
/**
* Entry point for a conversation screen.
*
* @param uiState [ConversationScreenState] 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: ConversationScreenState,
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.remotePhoneNumber,
channelMembers = 2, // TODO remove?
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),
)
scope.launch {
val networkClient = NetworkClient()
// FIXME
// networkClient.sendSmsMessage(content, uiState.localPhoneNumber, uiState.remotePhoneNumber)
}
},
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<Message>, 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 = exampleUiStateNew,
navigateToProfile = { },
)
}
}
@Preview
@Composable
fun ChannelBarPrev() {
JetchatTheme {
ChannelNameBar(channelName = "composers", channelMembers = 52)
}
}
@Preview
@Composable
fun DayHeaderPrev() {
DayHeader("Aug 6")
}
private val JumpToBottomThreshold = 56.dp
@@ -0,0 +1,82 @@
/*
* 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.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
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.exampleUiStateNew
import xyz.magicalbits.smsremote.theme.JetchatTheme
class ConversationFragment : Fragment() {
private val activityViewModel: MainViewModel by activityViewModels()
private val conversationViewModel: ConversationViewModel by activityViewModels()
var phoneNumber: String = ""
override fun onAttach(context: Context) {
super.onAttach(context)
// Consider using safe args plugin
val phoneNumber = arguments?.getString("phoneNumber")
this.phoneNumber = phoneNumber!!
// update view model with latest messages
conversationViewModel.setConversationData(phoneNumber)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View =
ComposeView(inflater.context).apply {
layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
setContent {
val conversationData by conversationViewModel.conversationData.observeAsState()
JetchatTheme {
ConversationContent(
uiState = conversationData ?: exampleUiStateNew,
navigateToProfile = { user ->
// Click callback
val bundle = bundleOf("userId" to user)
findNavController().navigate(
R.id.nav_profile,
bundle,
)
},
onNavIconPressed = {
activityViewModel.openDrawer()
},
)
}
}
}
}
@@ -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<Message>,
) {
private val _messages: MutableList<Message> = initialMessages.toMutableStateList()
val messages: List<Message> = _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,
)
@@ -0,0 +1,59 @@
package xyz.magicalbits.smsremote.conversation
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import xyz.magicalbits.smsremote.network.NetworkClient
class ConversationViewModel : ViewModel() {
private var remotePhoneNumber: String = ""
private val _conversationData = MutableLiveData<ConversationScreenState>()
val conversationData: LiveData<ConversationScreenState> = _conversationData
fun setConversationData(phoneNumber: String?) {
if (phoneNumber != null) {
this.remotePhoneNumber = phoneNumber
var messageDtoList: List<NetworkClient.SmsMessageDto> = listOf()
viewModelScope.launch {
val networkClient = NetworkClient()
messageDtoList = networkClient.getSmsMessagesByLocalPhoneNumber(phoneNumber)
}.invokeOnCompletion {
_conversationData.value =
ConversationScreenState(
remotePhoneNumber = this.remotePhoneNumber,
initialMessages = messageDtoList.map {
Message(
if (it.msg_type == "INCOMING") {
it.remote_phone_number
} else {
it.local_phone_number
},
it.content,
// FIXME convert to HH:MM AM/PM
it.ts_sent.toString(),
null,
)
},
)
println("sims live: ${_conversationData.value!!.initialMessages}")
}
}
}
}
data class ConversationScreenState(
val remotePhoneNumber: String,
val initialMessages: List<Message>,
) {
private val _messages: MutableList<Message> = initialMessages.toMutableStateList()
val messages: List<Message> = _messages
fun addMessage(msg: Message) {
_messages.add(0, msg)
}
}
@@ -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 = {})
}
@@ -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<String>
// Pair returning styled content and annotation for ClickableText when matching syntax token
typealias SymbolAnnotation = Pair<AnnotatedString, StringAnnotation?>
/**
* 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)
}
}
@@ -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<Boolean>("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
)
@@ -0,0 +1,75 @@
/*
* 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.ConversationScreenState
import xyz.magicalbits.smsremote.conversation.ConversationUiState
import xyz.magicalbits.smsremote.conversation.Message
import xyz.magicalbits.smsremote.profile.ProfileScreenState
val initialMessages =
listOf(
Message(
"Taylor Brooks",
"You can use all the same stuff",
"8:05 PM",
),
)
val unreadMessages = initialMessages.filter { it.author != "me" }
val exampleUiStateNew =
ConversationScreenState(
remotePhoneNumber = "",
initialMessages = mutableListOf()
)
val exampleUiState =
ConversationUiState("name", 123, listOf())
/**
* 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,
)
@@ -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))
}
@@ -0,0 +1,88 @@
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.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
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 deviceId = arguments?.getString("deviceId")
val name = arguments?.getString("name")
val type = arguments?.getString("type")
viewModel.setDeviceData(deviceId, name, type)
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false)
rootView.findViewById<ComposeView>(R.id.toolbar_compose_view).apply {
setContent {
JetchatTheme {
JetchatAppBar(
// Reset the minimum bounds that are passed to the root of a compose tree
modifier = Modifier.wrapContentSize(),
onNavIconPressed = { activityViewModel.openDrawer() },
title = { },
)
}
}
}
rootView.findViewById<ComposeView>(R.id.profile_compose_view).apply {
setContent {
val deviceData by viewModel.deviceData.observeAsState()
val nestedScrollInteropConnection = rememberNestedScrollInteropConnection()
JetchatTheme {
if (deviceData == null) {
println("calling device error")
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_sim, args)
},
)
}
}
}
}
return rootView
}
}
@@ -0,0 +1,48 @@
package xyz.magicalbits.smsremote.device
import androidx.compose.runtime.Immutable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import xyz.magicalbits.smsremote.network.NetworkClient
class DeviceViewModel : ViewModel() {
private var deviceId: String = ""
private val _deviceData = MutableLiveData<DeviceScreenState>()
val deviceData: LiveData<DeviceScreenState> = _deviceData
fun setDeviceData(newDeviceId: String?, name: String?, type: String?) {
if (newDeviceId != null && name != null) {
deviceId = newDeviceId
var simCardDtoList: List<NetworkClient.SimCardDto> = listOf()
viewModelScope.launch {
val networkClient = NetworkClient()
simCardDtoList = networkClient.getSimCardsByAccessKey(deviceId)
println("sims: $simCardDtoList")
}.invokeOnCompletion {
// FIXME waiting for the response causes brief moment of DeviceError() before _deviceData is updated ...
// a solution: caching SIM phone numbers of all discovered devices locally on startup and updating them
// only on startup (implicit behavior) or with a pull-down refresh action (not done yet)
// placeholder since there's no API reading logic yet
_deviceData.value =
DeviceScreenState(
deviceId = deviceId,
name = name,
phoneNumbers = simCardDtoList.map { it.phone_number },
)
println("sims live: ${_deviceData.value!!.phoneNumbers}")
}
}
}
}
@Immutable
data class DeviceScreenState(
val deviceId: String,
val name: String,
val phoneNumbers: List<String>
)
@@ -0,0 +1,108 @@
package xyz.magicalbits.smsremote.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.URLEncoder
class NetworkClient {
// TODO make apiBaseUrl configurable with fallback to my real domain
private val apiBaseUrl = "http://192.168.1.116:5000"
// TODO ask user for creds, call login endpoint, store tokens in local encrypted storage
private val networkClient = HttpClient(CIO) {
install(ContentNegotiation) {
json()
}
install(Auth) {
bearer {
// TODO configure refresh token so it refreshes the access token
loadTokens {
// TODO load from local encrypted storage
BearerTokens(
"test",
"test"
)
}
}
}
}
@Serializable()
data class DeviceDto(val access_key: String, val name: String, val type: String)
@Serializable
data class SimCardDto(val device_access_key: String, val phone_number: String)
@Serializable
data class SmsMessageDto(val content: String, val ts_received: Int, val ts_sent: Int, val msg_type: String, val local_phone_number: String, val remote_phone_number: String)
@Serializable
data class OutgoingMessage(val content: String, val local_phone_number: String, val remote_phone_number: String)
@Serializable
data class ConversationPreviewDto(val remote_phone_number: String, val last_message_content: String, val message_timestamp: Int)
// GET /api/v1/devices
suspend fun getDevices(): List<DeviceDto> {
// TODO handle non-200 status codes
// TODO handle '{"msg":"Token has expired"}' messages
val data = networkClient.get("$apiBaseUrl/api/v1/devices").bodyAsText()
return Json.decodeFromString(data)
}
// GET /api/v1/sim-cards
suspend fun getSimCardsByAccessKey(accessKey: String): List<SimCardDto> {
// TODO handle non-200 status codes
// TODO handle '{"msg":"Token has expired"}' messages
val data = networkClient.get("$apiBaseUrl/api/v1/sim-cards?access_key=$accessKey").bodyAsText()
return Json.decodeFromString(data)
}
// GET /api/v1/sms-messages
suspend fun getSmsMessagesByLocalPhoneNumber(phoneNumber: String): List<SmsMessageDto> {
// TODO handle non-200 status codes
// TODO handle '{"msg":"Token has expired"}' messages
// TODO extract encoder to a function here or to a utility class
val encodedPhoneNumber = withContext(Dispatchers.IO) {
URLEncoder.encode(phoneNumber, "UTF-8")
}
val data = networkClient.get("$apiBaseUrl/api/v1/sms-messages?local_phone_number=$encodedPhoneNumber").bodyAsText()
return Json.decodeFromString(data)
}
// POST /api/v1/send-message
suspend fun sendSmsMessage(content: String, localPhoneNumber: String, remotePhoneNumber: String) {
println("sending SMS message: content=$content, lPN=$localPhoneNumber, rPN=$remotePhoneNumber")
val response = networkClient.post("$apiBaseUrl/api/v1/send-message") {
contentType(ContentType.Application.Json)
setBody(OutgoingMessage(content, localPhoneNumber, remotePhoneNumber))
}
println("sending SMS message: status code ${response.status}")
}
// GET /api/v1/conversation-previews
suspend fun getConversationPreviews(simPhoneNumber: String): List<ConversationPreviewDto> {
// TODO extract encoder to a function here or to a utility class
val encodedPhoneNumber = withContext(Dispatchers.IO) {
URLEncoder.encode(simPhoneNumber, "UTF-8")
}
val response = networkClient.get("$apiBaseUrl/api/v1/conversation-previews?local_phone_number=$encodedPhoneNumber")
return Json.decodeFromString(response.bodyAsText())
}
}
@@ -0,0 +1,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)
}
}
@@ -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)
}
}
@@ -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<ComposeView>(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<ComposeView>(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
}
}
@@ -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<ProfileScreenState>()
val userData: LiveData<ProfileScreenState> = _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
}
@@ -0,0 +1,119 @@
package xyz.magicalbits.smsremote.sim
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import xyz.magicalbits.smsremote.R
import xyz.magicalbits.smsremote.components.baselineHeight
import xyz.magicalbits.smsremote.theme.JetchatTheme
@Composable
fun SimScreen(
simData: SimScreenState,
nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
onConversationClicked: (String) -> Unit = { },
) {
val scrollState = rememberScrollState()
Box(
modifier = Modifier.fillMaxSize().nestedScroll(nestedScrollInteropConnection)
) {
Surface {
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(horizontal = 16.dp)) {
Name(simData, Modifier.baselineHeight(32.dp))
Spacer(modifier = Modifier.height(8.dp))
simData.conversations.forEach {
ConversationPreview(it, onConversationClicked)
}
}
}
}
}
@Composable
private fun Name(
simData: SimScreenState,
modifier: Modifier
) {
Text(
text = simData.phoneNumber,
modifier = modifier,
style = MaterialTheme.typography.headlineSmall
)
}
@Composable
private fun ConversationPreview(
conversation: ConversationRowState,
onConversationClicked: (String) -> Unit,
) {
Column(modifier = Modifier
.clickable(
onClick = {
onConversationClicked(conversation.phoneNumber)
}
)
) {
HorizontalDivider()
Row(
modifier = Modifier
.wrapContentHeight()
.padding(start = 16.dp, end = 16.dp)
) {
Text(text = conversation.phoneNumber)
Spacer(modifier = Modifier.weight(1f))
Text(text = conversation.messageTimestamp.toString())
}
Row(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp)
) {
Text(text = conversation.lastMessage)
}
}
}
@Preview
@Composable
fun SimScreenPreview() {
JetchatTheme {
SimScreen(
simData = SimScreenState(
phoneNumber = "+420123456789",
conversations = listOf(
ConversationRowState("+15558880000", "last msg", 12345),
ConversationRowState("+15558880000", "last msg", 12345),
ConversationRowState("+15558880000", "last msg", 12345),
)
)
)
}
}
@Composable
fun SimError() {
Text(stringResource(R.string.sim_error))
}
@@ -0,0 +1,95 @@
package xyz.magicalbits.smsremote.sim
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.findNavController
import xyz.magicalbits.smsremote.MainViewModel
import xyz.magicalbits.smsremote.R
import xyz.magicalbits.smsremote.components.JetchatAppBar
import xyz.magicalbits.smsremote.theme.JetchatTheme
class SimFragment : Fragment() {
private val viewModel: SimViewModel by viewModels()
private val activityViewModel: MainViewModel by activityViewModels()
override fun onAttach(context: Context) {
super.onAttach(context)
// Consider using safe args plugin
val phoneNumber = arguments?.getString("phoneNumber")
viewModel.setSimData(phoneNumber)
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false)
rootView.findViewById<ComposeView>(R.id.toolbar_compose_view).apply {
setContent {
JetchatTheme {
JetchatAppBar(
// Reset the minimum bounds that are passed to the root of a compose tree
modifier = Modifier.wrapContentSize(),
onNavIconPressed = { activityViewModel.openDrawer() },
title = { },
)
}
}
}
rootView.findViewById<ComposeView>(R.id.profile_compose_view).apply {
setContent {
val simData by viewModel.simData.observeAsState()
val nestedScrollInteropConnection = rememberNestedScrollInteropConnection()
JetchatTheme {
// TODO flip if condition after integrating API call
if (simData != null) {
SimError()
println("calling sim error")
} else {
val navController: NavController = rootView.findNavController()
SimScreen(
// simData = simData!!,
// TODO remove fake data after integrating API call
simData = SimScreenState(
phoneNumber = "+420123456789",
conversations = listOf(
ConversationRowState("+15558880000", "last msg", 12345),
ConversationRowState("+15558880111", "last msg", 12345),
ConversationRowState("+15558880333", "last msg", 12345),
)
),
nestedScrollInteropConnection = nestedScrollInteropConnection,
onConversationClicked = {
val args = Bundle(1)
args.putString("phoneNumber", it)
navController.navigate(R.id.action_sim_to_conversation, args)
},
)
}
}
}
}
return rootView
}
}
@@ -0,0 +1,56 @@
package xyz.magicalbits.smsremote.sim
import androidx.compose.runtime.Immutable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import xyz.magicalbits.smsremote.network.NetworkClient
class SimViewModel : ViewModel() {
private var simPhoneNumber: String = ""
private val _simConversations = MutableLiveData<SimScreenState>()
val simData: LiveData<SimScreenState> = _simConversations
fun setSimData(phoneNumber: String?) {
if (phoneNumber != null) {
simPhoneNumber = phoneNumber
var conversationDtoList: List<NetworkClient.ConversationPreviewDto> = listOf()
// TODO uncomment after adding the corresponding API endpoint
// viewModelScope.launch {
// val networkClient = NetworkClient()
// conversationDtoList = networkClient.getConversationPreviews(simPhoneNumber)
// println("conv previews: $conversationDtoList")
// }.invokeOnCompletion {
// _simConversations.value =
// SimScreenState(
// phoneNumber = simPhoneNumber,
// conversations = conversationDtoList.map { convertFromDto(it) }.toList()
// )
// }
}
}
}
fun convertFromDto(conversation: NetworkClient.ConversationPreviewDto): ConversationRowState {
return ConversationRowState(
phoneNumber = conversation.remote_phone_number,
lastMessage = conversation.last_message_content,
messageTimestamp = conversation.message_timestamp,
)
}
@Immutable
data class SimScreenState(
val phoneNumber: String,
val conversations: List<ConversationRowState>,
)
@Immutable
data class ConversationRowState(
val phoneNumber: String,
val lastMessage: String,
val messageTimestamp: Int,
)
@@ -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)
@@ -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,
)
}
@@ -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,
),
)
@@ -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())
}
}
}
}
@@ -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()
}
@@ -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<Message>) {
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<NavActivity>()).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")))
}
@@ -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,
)
}
@@ -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,
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

@@ -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
~
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.9818182"
android:scaleY="0.9818182">
<path
android:pathData="M65,43.524L71.5,43.524L74.859,46.859L85.281,57.281L106.125,78.125L77.776,106.473L36.089,64.786L43.176,57.699L48.5,55.999L41,47.5L45.5,43.524L49.5,39.499L56,45.999L58,43.999L65,43.524Z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="60.7846"
android:startX="72.4963"
android:endY="125.024"
android:endX="68.6386"
android:type="linear">
<item android:offset="0" android:color="#FF0037E6"/>
<item android:offset="0.5" android:color="#FF0540F2"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H69.105C72.361,43.371 75,46.01 75,49.266V49.266C75,52.521 72.361,55.161 69.105,55.161H60.516C57.26,55.161 54.621,52.521 54.621,49.266V49.266Z"
android:fillColor="#F3B711"/>
<path
android:pathData="M66.916,43.371C66.916,43.371 66.916,43.371 66.916,43.371C66.916,46.627 64.277,49.266 61.021,49.266H54.621C54.621,49.266 54.621,49.266 54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H66.916Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M35,61.055C35,57.799 37.639,55.16 40.895,55.16H49.653C52.908,55.16 55.547,57.799 55.547,61.055V61.055C55.547,64.311 52.908,66.95 49.653,66.95H40.895C37.639,66.95 35,64.311 35,61.055V61.055Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M55.547,61.055C55.547,61.055 55.547,61.055 55.547,61.055C55.547,64.311 52.908,66.95 49.653,66.95H42.832C42.832,66.95 42.832,66.95 42.832,66.949C42.832,63.694 45.471,61.055 48.726,61.055H55.547Z"
android:fillColor="#F3B711"
android:fillType="evenOdd"/>
<path
android:pathData="M59,66.949C59,63.694 61.639,61.055 64.895,61.055V61.055C68.15,61.055 70.789,63.694 70.789,66.949V66.949C70.789,70.205 68.15,72.844 64.895,72.844V72.844C61.639,72.844 59,70.205 59,66.949V66.949Z"
android:fillColor="#F3B711"/>
<path
android:pathData="M39.379,43.371C39.379,40.116 42.018,37.477 45.274,37.477V37.477C48.529,37.477 51.168,40.116 51.168,43.371V43.371C51.168,46.627 48.529,49.266 45.274,49.266V49.266C42.018,49.266 39.379,46.627 39.379,43.371V43.371Z"
android:fillColor="#ffffff"/>
</group>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480L880,538Q880,597 839.5,638.5Q799,680 740,680Q705,680 674,665Q643,650 622,622Q593,651 556.5,665.5Q520,680 480,680Q397,680 338.5,621.5Q280,563 280,480Q280,397 338.5,338.5Q397,280 480,280Q563,280 621.5,338.5Q680,397 680,480L680,538Q680,564 697,582Q714,600 740,600Q766,600 783,582Q800,564 800,538L800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800L680,800L680,880L480,880ZM480,600Q530,600 565,565Q600,530 600,480Q600,430 565,395Q530,360 480,360Q430,360 395,395Q360,430 360,480Q360,530 395,565Q430,600 480,600Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M440,160L440,647L216,423L160,480L480,800L800,480L744,423L520,647L520,160L440,160Z"/>
</vector>
@@ -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
~
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/blue500"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M200,760L257,760L648,369L591,312L200,703L200,760ZM120,840L120,670L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L290,840L120,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z"/>
</vector>
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480L800,160Q800,160 800,160Q800,160 800,160L480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM280,600L560,600L560,520L680,600L680,360L560,440L560,360L280,360L280,600ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM200,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760ZM240,680L720,680L570,480L450,640L360,520L240,680ZM200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760Z"/>
</vector>
@@ -0,0 +1,42 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M15.6968,11.4122C15.6968,8.8077 17.8081,6.6964 20.4126,6.6964H27.2841C29.8886,6.6964 31.9999,8.8077 31.9999,11.4122C31.9999,14.0167 29.8886,16.128 27.2841,16.128H20.4126C17.8081,16.128 15.6968,14.0167 15.6968,11.4122Z"
android:fillColor="@color/yellow_logo"/>
<path
android:pathData="M25.5326,6.6964C25.5326,6.6965 25.5326,6.6965 25.5326,6.6966C25.5326,9.3011 23.4212,11.4124 20.8168,11.4124H15.6968C15.6968,11.4123 15.6968,11.4123 15.6968,11.4122C15.6968,8.8077 17.8081,6.6964 20.4126,6.6964H25.5326Z"
android:fillColor="@color/blue_logo"
android:fillType="evenOdd"/>
<path
android:pathData="M0,20.8438C0,18.2393 2.1113,16.128 4.7158,16.128H11.7221C14.3266,16.128 16.4379,18.2393 16.4379,20.8438C16.4379,23.4482 14.3266,25.5596 11.7221,25.5596H4.7158C2.1113,25.5596 0,23.4482 0,20.8438Z"
android:fillColor="@color/blue_logo"/>
<path
android:pathData="M16.4379,20.8438C16.4379,20.8439 16.4379,20.8439 16.4379,20.844C16.4379,23.4485 14.3266,25.5598 11.7221,25.5598H6.2653C6.2653,25.5597 6.2653,25.5597 6.2653,25.5596C6.2653,22.9551 8.3766,20.8438 10.981,20.8438H16.4379Z"
android:fillColor="@color/yellow_logo"
android:fillType="evenOdd"/>
<path
android:pathData="M19.2,25.5596C19.2,22.9551 21.3113,20.8438 23.9157,20.8438C26.5202,20.8438 28.6315,22.9551 28.6315,25.5596C28.6315,28.1641 26.5202,30.2754 23.9157,30.2754C21.3113,30.2754 19.2,28.1641 19.2,25.5596Z"
android:fillColor="@color/yellow_logo"/>
<path
android:pathData="M3.5032,6.6964C3.5032,4.092 5.6145,1.9806 8.219,1.9806C10.8234,1.9806 12.9348,4.092 12.9348,6.6964C12.9348,9.3009 10.8234,11.4122 8.219,11.4122C5.6145,11.4122 3.5032,9.3009 3.5032,6.6964Z"
android:fillColor="@color/blue_logo"/>
</vector>
@@ -0,0 +1,32 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M15.6968,11.4122C15.6968,8.8077 17.8081,6.6964 20.4126,6.6964H27.2841C29.8886,6.6964 31.9999,8.8077 31.9999,11.4122C31.9999,14.0167 29.8886,16.128 27.2841,16.128H20.4126C17.8081,16.128 15.6968,14.0167 15.6968,11.4122Z"
android:fillColor="@color/yellow_logo"/>
<path
android:pathData="M16.4379,20.8438C16.4379,20.8439 16.4379,20.8439 16.4379,20.844C16.4379,23.4485 14.3266,25.5598 11.7221,25.5598H6.2653C6.2653,25.5597 6.2653,25.5597 6.2653,25.5596C6.2653,22.9551 8.3766,20.8438 10.981,20.8438H16.4379Z"
android:fillColor="@color/yellow_logo"
android:fillType="evenOdd"/>
<path
android:pathData="M19.2,25.5596C19.2,22.9551 21.3113,20.8438 23.9157,20.8438C26.5202,20.8438 28.6315,22.9551 28.6315,25.5596C28.6315,28.1641 26.5202,30.2754 23.9157,30.2754C21.3113,30.2754 19.2,28.1641 19.2,25.5596Z"
android:fillColor="@color/yellow_logo"/>
</vector>
@@ -0,0 +1,32 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M25.5326,6.6964C25.5326,6.6965 25.5326,6.6965 25.5326,6.6966C25.5326,9.3011 23.4212,11.4124 20.8168,11.4124H15.6968C15.6968,11.4123 15.6968,11.4123 15.6968,11.4122C15.6968,8.8077 17.8081,6.6964 20.4126,6.6964H25.5326Z"
android:fillColor="@color/blue_logo"
android:fillType="evenOdd"/>
<path
android:pathData="M0,20.8438C0,18.2393 2.1113,16.128 4.7158,16.128H11.7221C14.3266,16.128 16.4379,18.2393 16.4379,20.8438C16.4379,23.4482 14.3266,25.5596 11.7221,25.5596H4.7158C2.1113,25.5596 0,23.4482 0,20.8438Z"
android:fillColor="@color/blue_logo"/>
<path
android:pathData="M3.5032,6.6964C3.5032,4.092 5.6145,1.9806 8.219,1.9806C10.8234,1.9806 12.9348,4.092 12.9348,6.6964C12.9348,9.3009 10.8234,11.4122 8.219,11.4122C5.6145,11.4122 3.5032,9.3009 3.5032,6.6964Z"
android:fillColor="@color/blue_logo"/>
</vector>
@@ -0,0 +1,57 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M65,43.524L71.5,43.524L74.859,46.859L85.281,57.281L106.125,78.125L77.776,106.473L36.089,64.786L43.176,57.699L48.5,55.999L41,47.5L45.5,43.524L49.5,39.499L56,45.999L58,43.999L65,43.524Z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="60.7846"
android:startX="72.4963"
android:endY="125.024"
android:endX="68.6386"
android:type="linear">
<item android:offset="0" android:color="#FF0037E6"/>
<item android:offset="0.5" android:color="#FF0540F2"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H69.105C72.361,43.371 75,46.01 75,49.266V49.266C75,52.521 72.361,55.161 69.105,55.161H60.516C57.26,55.161 54.621,52.521 54.621,49.266V49.266Z"
android:fillColor="#F3B711"/>
<path
android:pathData="M66.916,43.371C66.916,43.371 66.916,43.371 66.916,43.371C66.916,46.627 64.277,49.266 61.021,49.266H54.621C54.621,49.266 54.621,49.266 54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H66.916Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M35,61.055C35,57.799 37.639,55.16 40.895,55.16H49.653C52.908,55.16 55.547,57.799 55.547,61.055V61.055C55.547,64.311 52.908,66.95 49.653,66.95H40.895C37.639,66.95 35,64.311 35,61.055V61.055Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M55.547,61.055C55.547,61.055 55.547,61.055 55.547,61.055C55.547,64.311 52.908,66.95 49.653,66.95H42.832C42.832,66.95 42.832,66.95 42.832,66.949C42.832,63.694 45.471,61.055 48.726,61.055H55.547Z"
android:fillColor="#F3B711"
android:fillType="evenOdd"/>
<path
android:pathData="M59,66.949C59,63.694 61.639,61.055 64.895,61.055V61.055C68.15,61.055 70.789,63.694 70.789,66.949V66.949C70.789,70.205 68.15,72.844 64.895,72.844V72.844C61.639,72.844 59,70.205 59,66.949V66.949Z"
android:fillColor="#F3B711"/>
<path
android:pathData="M39.379,43.371C39.379,40.116 42.018,37.477 45.274,37.477V37.477C48.529,37.477 51.168,40.116 51.168,43.371V43.371C51.168,46.627 48.529,49.266 45.274,49.266V49.266C42.018,49.266 39.379,46.627 39.379,43.371V43.371Z"
android:fillColor="#ffffff"/>
</vector>
@@ -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
~
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#ffffff"
android:pathData="M54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H69.105C72.361,43.371 75,46.01 75,49.266V49.266C75,52.521 72.361,55.161 69.105,55.161H60.516C57.26,55.161 54.621,52.521 54.621,49.266V49.266Z" />
<path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M66.916,43.371C66.916,43.371 66.916,43.371 66.916,43.371C66.916,46.627 64.277,49.266 61.021,49.266H54.621C54.621,49.266 54.621,49.266 54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H66.916Z" />
<path
android:fillColor="#ffffff"
android:pathData="M35,61.055C35,57.799 37.639,55.16 40.895,55.16H49.653C52.908,55.16 55.547,57.799 55.547,61.055V61.055C55.547,64.311 52.908,66.95 49.653,66.95H40.895C37.639,66.95 35,64.311 35,61.055V61.055Z" />
<path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M55.547,61.055C55.547,61.055 55.547,61.055 55.547,61.055C55.547,64.311 52.908,66.95 49.653,66.95H42.832C42.832,66.95 42.832,66.95 42.832,66.949C42.832,63.694 45.471,61.055 48.726,61.055H55.547Z" />
<path
android:fillColor="#ffffff"
android:pathData="M59,66.949C59,63.694 61.639,61.055 64.895,61.055V61.055C68.15,61.055 70.789,63.694 70.789,66.949V66.949C70.789,70.205 68.15,72.844 64.895,72.844V72.844C61.639,72.844 59,70.205 59,66.949V66.949Z" />
<path
android:fillColor="#ffffff"
android:pathData="M39.379,43.371C39.379,40.116 42.018,37.477 45.274,37.477V37.477C48.529,37.477 51.168,40.116 51.168,43.371V43.371C51.168,46.627 48.529,49.266 45.274,49.266V49.266C42.018,49.266 39.379,46.627 39.379,43.371V43.371Z" />
</vector>
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.2-3c0 2.75-2.25 5-5 5s-5-2.25-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-0.49 6-3.39 6-6.92h-2z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M620,440Q645,440 662.5,422.5Q680,405 680,380Q680,355 662.5,337.5Q645,320 620,320Q595,320 577.5,337.5Q560,355 560,380Q560,405 577.5,422.5Q595,440 620,440ZM340,440Q365,440 382.5,422.5Q400,405 400,380Q400,355 382.5,337.5Q365,320 340,320Q315,320 297.5,337.5Q280,355 280,380Q280,405 297.5,422.5Q315,440 340,440ZM480,700Q548,700 603.5,661.5Q659,623 684,560L276,560Q301,623 356.5,661.5Q412,700 480,700ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,480Q513,480 536.5,456.5Q560,433 560,400Q560,367 536.5,343.5Q513,320 480,320Q447,320 423.5,343.5Q400,367 400,400Q400,433 423.5,456.5Q447,480 480,480ZM480,774Q602,662 661,570.5Q720,479 720,408Q720,299 650.5,229.5Q581,160 480,160Q379,160 309.5,229.5Q240,299 240,408Q240,479 299,570.5Q358,662 480,774ZM480,880Q319,743 239.5,625.5Q160,508 160,408Q160,258 256.5,169Q353,80 480,80Q607,80 703.5,169Q800,258 800,408Q800,508 720.5,625.5Q641,743 480,880ZM480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z"/>
</vector>
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/mobile_navigation"
tools:ignore="FragmentTagUsage" />
</FrameLayout>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2022 Google LLC
~
~ 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.
-->
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/coordinator_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.compose.ui.platform.ComposeView
android:id="@+id/toolbar_compose_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|exitUntilCollapsed"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/profile_compose_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:showIn="navigation_view">
<group android:checkableBehavior="single">
<item
android:id="@+id/nav_conversation"
android:icon="@drawable/ic_jetchat"
android:title="@string/conversations" />
</group>
</menu>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/mobile_navigation"
app:startDestination="@+id/nav_device">
<fragment
android:id="@+id/nav_conversation"
android:name="xyz.magicalbits.smsremote.conversation.ConversationFragment"
android:label="Conversation">
</fragment>
<fragment
android:id="@+id/nav_profile"
android:name="xyz.magicalbits.smsremote.profile.ProfileFragment"
android:label="Profile">
<argument
android:name="userId"
app:argType="string" />
</fragment>
<!-- TODO add a new fragment class for a blank home screen (to be eventually turned into a device selection screen) -->
<fragment
android:id="@+id/nav_device"
android:name="xyz.magicalbits.smsremote.device.DeviceFragment"
android:label="Device">
<argument
android:name="deviceId"
app:argType="string" />
<argument
android:name="name"
app:argType="string" />
<argument
android:name="type"
app:argType="string" />
<action
android:id="@+id/action_device_to_sim"
app:destination="@id/nav_sim"
/>
</fragment>
<fragment
android:id="@+id/nav_sim"
android:name="xyz.magicalbits.smsremote.sim.SimFragment"
android:label="Sim">
<argument
android:name="phoneNumber"
app:argType="string" />
<action
android:id="@+id/action_sim_to_conversation"
app:destination="@id/nav_conversation"
/>
</fragment>
<!-- TODO add a new fragment class (based on ConversationFragment) serving as a device screen opened from the home screen -->
</navigation>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<resources>
<!-- Logo -->
<color name="yellow_logo">@color/yellow400</color>
<color name="blue_logo">@color/blue300</color>
</resources>
@@ -0,0 +1,26 @@
<!--
~ 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.
-->
<resources>
<!-- Our dark theme -->
<style name="Theme.Jetchat" parent="Platform.Theme.Jetchat" >
<item name="colorPrimary">@color/blue300</item>
<item name="colorPrimaryDark">@color/blue400</item>
<item name="colorAccent">@color/yellow400</item>
</style>
</resources>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<resources>
<array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>
MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=
</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>
MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK
</item>
</string-array>
</resources>
@@ -0,0 +1,24 @@
<!--
~ 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.
-->
<resources>
<style name="Platform.Theme.Jetchat" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
</style>
</resources>
@@ -0,0 +1,26 @@
<!--
~ 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.
-->
<resources>
<style name="Platform.Theme.Jetchat" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightNavigationBar">?attr/isLightTheme</item>
</style>
</resources>
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<resources>
<!-- Light -->
<color name="blue500">#0540F2</color>
<color name="blue800">#001CCF</color>
<color name="yellow700">#F3B711</color>
<!-- Dark -->
<color name="blue300">#6F7EF9</color>
<color name="blue400">#4860F7</color>
<color name="yellow400">#F6E547</color>
<!-- Logo -->
<color name="yellow_logo">@color/yellow700</color>
<color name="blue_logo">@color/blue500</color>
<!-- Status bar -->
<color name="black30">#4D000000</color>
</resources>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<resources>
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="nav_header_spacing">16dp</dimen>
<dimen name="nav_header_logo_spacing">8dp</dimen>
<dimen name="nav_header_logo_size">24dp</dimen>
<dimen name="fab_margin">16dp</dimen>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<resources>
<color name="ic_launcher_background">#0540F2</color>
</resources>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<resources>
<item name="conversation_fragment" type="id" />
<item name="profile_fragment" type="id" />
</resources>
+79
View File
@@ -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
~
~ 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.
-->
<resources>
<string name="app_name">SMS Remote</string>
<string name="navigation_drawer_open">Open navigation drawer</string>
<string name="navigation_drawer_close">Close navigation drawer</string>
<string name="nav_header_title">Composer</string>
<string name="nav_header_subtitle">android.studio@android.com</string>
<string name="nav_header_desc">Navigation header</string>
<string name="action_settings">Settings</string>
<string name="menu_home">Home</string>
<string name="menu_gallery">Gallery</string>
<string name="menu_slideshow">Slideshow</string>
<string name="conversations">Conversations</string>
<string name="profile">Profile</string>
<string name="jumpBottom">Jump to bottom</string>
<string name="send">Send</string>
<string name="author_me">me</string>
<string name="now">8:30 PM</string>
<string name="members">%d members</string>
<string name="textfield_hint">Message #composers</string>
<string name="swipe_to_cancel_recording">&#x25C0; Swipe to cancel</string>
<string name="emojis_label">Emojis</string>
<string name="stickers_label">Stickers</string>
<string name="message">Message</string>
<string name="edit_profile">Edit Profile</string>
<string name="profile_error">There was an error loading the profile</string>
<string name="bio">Bio</string>
<string name="display_name">Display name</string>
<string name="status">Status</string>
<string name="timezone">Timezone</string>
<string name="twitter">Twitter</string>
<string name="common_channels">Channels in common</string>
<string name="lorem">Lorem or Ipsum</string>
<!-- Device -->
<string name="device_error">There was an error loading the device</string>
<!-- Sim -->
<string name="sim_error">There was an error loading the SIM</string>
<!-- Accessibility descriptions -->
<string name="emoji_selector_desc">Emoji selector</string>
<string name="emoji_selector_bt_desc">Show Emoji selector</string>
<string name="dm_desc">Direct Message</string>
<string name="attach_photo_desc">Attach Photo</string>
<string name="map_selector_desc">Location selector</string>
<string name="videochat_desc">Start videochat</string>
<string name="textfield_desc">Text input</string>
<string name="not_available">Functionality currently not available</string>
<string name="not_available_subtitle">Grab a beverage and check back later!</string>
<string name="attached_image">Attached image</string>
<string name="search">Search</string>
<string name="info">Information</string>
<string name="more_options">More options</string>
<string name="touch_and_hold_to_record">Touch and hold to record</string>
<string name="record_message">Record voice message</string>
<string name="messages_widget_title">JetChat unread messages</string>
<string name="add_widget_to_home_page">Add Widget to Home Page</string>
</resources>
+42
View File
@@ -0,0 +1,42 @@
<!--
~ 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.
-->
<resources>
<!-- Allows us to override platform level specific attributes in their
respective values-vXX folder. -->
<style name="Platform.Theme.Jetchat" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/black30</item>
</style>
<!-- The actual theme we use. This varies for light theme (here),
and values-night for dark theme. -->
<style name="Theme.Jetchat" parent="Platform.Theme.Jetchat">
<item name="colorPrimary">@color/blue500</item>
<item name="colorPrimaryDark">@color/blue800</item>
<item name="colorAccent">@color/yellow700</item>
</style>
<style name="Theme.Jetchat.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="Theme.Jetchat.AppBarOverlay" parent="ThemeOverlay.MaterialComponents.Dark.ActionBar" />
<style name="Theme.Jetchat.PopupOverlay" parent="ThemeOverlay.MaterialComponents.Light" />
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="276dp"
android:minHeight="102dp"
android:previewImage="@drawable/widget_icon"
android:resizeMode="none"
android:targetCellWidth="4"
android:targetCellHeight="3" />
+2
View File
@@ -93,6 +93,8 @@ def get_sms_messages_by_local_phone_number():
return make_response(jsonify(msg=msg_403_not_primary), 403)
local_phone_number = request.args.get("local_phone_number", None)
# TODO set up access logging
#print(f"/api/v1/sms-messages - local_phone_number='{local_phone_number}'")
return make_response(jsonify([n.to_dict() for n in db.get_sms_messages_by_local_phone_number(cur, local_phone_number)]), 200)
+1 -1
View File
@@ -1,2 +1,2 @@
uv run gunicorn app:app \
uv run gunicorn -b 0.0.0.0:5000 app:app \
--reload
+46
View File
@@ -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<com.diffplug.gradle.spotless.SpotlessExtension> {
// 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"), "(^(?![\\/ ]\\*).*$)")
// }
}
}
+41
View File
@@ -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')
}
}
}
}
}
+39
View File
@@ -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
+13
View File
@@ -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
+181
View File
@@ -0,0 +1,181 @@
#####
# 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"
ktor-client = "3.5.0"
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" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor-client"}
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor-client"}
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor-client"}
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor-client"}
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-client"}
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" }
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" }
Binary file not shown.
+9
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
Vendored
+82
View File
@@ -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%
+17
View File
@@ -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")
+16
View File
@@ -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.
*/