diff --git a/.gitignore b/.gitignore index a217e90..9552451 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,8 @@ log/ *.db # Gradle +.gradle/ local.properties + +# IDEA +.idea/ diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..43414fb --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,128 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "xyz.magicalbits.smsremote" + + defaultConfig { + applicationId = "xyz.magicalbits.smsremote" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + vectorDrawables.useSupportLibrary = true + } + + signingConfigs { + // Important: change the keystore for a production deployment + val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore") + val localKeystore = rootProject.file("debug_2.keystore") + val hasKeyInfo = userKeystore.exists() + create("release") { + storeFile = if (hasKeyInfo) userKeystore else localKeystore + storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password") + keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias") + keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password") + } + } + + buildTypes { + getByName("debug") { + } + + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + viewBinding = true + } + + packaging.resources { + // Multiple dependency bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.androidx.glance.appwidget) + implementation(libs.androidx.glance.material3) + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.android) + + implementation(libs.androidx.activity.compose) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.compose.runtime.livedata) + implementation(libs.androidx.lifecycle.viewModelCompose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation.ui.ktx) + + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui.util) + implementation(libs.androidx.compose.ui.viewbinding) + implementation(libs.androidx.compose.ui.googlefonts) + + debugImplementation(libs.androidx.compose.ui.test.manifest) + + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) +} diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..676ce5b --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/MainViewModel.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/MainViewModel.kt new file mode 100644 index 0000000..45e0128 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/MainViewModel.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Used to communicate between screens. + */ +class MainViewModel : ViewModel() { + private val _drawerShouldBeOpened = MutableStateFlow(false) + val drawerShouldBeOpened = _drawerShouldBeOpened.asStateFlow() + + fun openDrawer() { + _drawerShouldBeOpened.value = true + } + + fun resetOpenDrawerAction() { + _drawerShouldBeOpened.value = false + } +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/NavActivity.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/NavActivity.kt new file mode 100644 index 0000000..7afd641 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/NavActivity.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote + +import android.os.Bundle +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.material3.DrawerValue.Closed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.viewinterop.AndroidViewBinding +import androidx.core.os.bundleOf +import androidx.core.view.ViewCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import kotlinx.coroutines.launch +import xyz.magicalbits.smsremote.components.JetchatDrawer +import xyz.magicalbits.smsremote.databinding.ContentMainBinding + +/** + * Main activity for the app. + */ +class NavActivity : AppCompatActivity() { + private val viewModel: MainViewModel by viewModels() + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> insets } + + setContentView( + ComposeView(this).apply { + consumeWindowInsets = false + setContent { + val drawerState = rememberDrawerState(initialValue = Closed) + val drawerOpen by viewModel.drawerShouldBeOpened + .collectAsStateWithLifecycle() + + var selectedMenu by remember { mutableStateOf("Samsung A14") } + if (drawerOpen) { + // Open drawer and reset state in VM. + LaunchedEffect(Unit) { + // wrap in try-finally to handle interruption whiles opening drawer + try { + drawerState.open() + } finally { + viewModel.resetOpenDrawerAction() + } + } + } + + val scope = rememberCoroutineScope() + + JetchatDrawer( + drawerState = drawerState, + selectedMenu = selectedMenu, + onChatClicked = { + findNavController().popBackStack(R.id.nav_device, false) + val args = Bundle(1) + args.putString("deviceName", it) + findNavController().navigate(R.id.nav_device, args) + scope.launch { + drawerState.close() + } + selectedMenu = it + }, + ) { + AndroidViewBinding(ContentMainBinding::inflate) + } + } + }, + ) + } + + override fun onSupportNavigateUp(): Boolean = findNavController().navigateUp() || super.onSupportNavigateUp() + + /** + * See https://issuetracker.google.com/142847973 + */ + private fun findNavController(): NavController { + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment + return navHostFragment.navController + } +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/UiExtras.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/UiExtras.kt new file mode 100644 index 0000000..8b85341 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/UiExtras.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +@Composable +fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + text = { + Text( + text = "Functionality not available \uD83D\uDE48", + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(text = "CLOSE") + } + }, + ) +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/components/AnimatingFabContent.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/AnimatingFabContent.kt new file mode 100644 index 0000000..6de824d --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/AnimatingFabContent.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.components + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.util.lerp +import kotlin.math.roundToInt + +/** + * A layout that shows an icon and a text element used as the content for a FAB that extends with + * an animation. + */ +@Composable +fun AnimatingFabContent( + icon: @Composable () -> Unit, + text: @Composable () -> Unit, + modifier: Modifier = Modifier, + extended: Boolean = true, +) { + val currentState = if (extended) ExpandableFabStates.Extended else ExpandableFabStates.Collapsed + val transition = updateTransition(currentState, "fab_transition") + + val textOpacity by transition.animateFloat( + transitionSpec = { + if (targetState == ExpandableFabStates.Collapsed) { + tween( + easing = LinearEasing, + durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames + ) + } else { + tween( + easing = LinearEasing, + delayMillis = (transitionDuration / 3f).roundToInt(), // 4 / 12 frames + durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames + ) + } + }, + label = "fab_text_opacity", + ) { state -> + if (state == ExpandableFabStates.Collapsed) { + 0f + } else { + 1f + } + } + val fabWidthFactor by transition.animateFloat( + transitionSpec = { + if (targetState == ExpandableFabStates.Collapsed) { + tween( + easing = FastOutSlowInEasing, + durationMillis = transitionDuration, + ) + } else { + tween( + easing = FastOutSlowInEasing, + durationMillis = transitionDuration, + ) + } + }, + label = "fab_width_factor", + ) { state -> + if (state == ExpandableFabStates.Collapsed) { + 0f + } else { + 1f + } + } + // Deferring reads using lambdas instead of Floats here can improve performance, + // preventing recompositions. + IconAndTextRow( + icon, + text, + { textOpacity }, + { fabWidthFactor }, + modifier = modifier, + ) +} + +@Composable +private fun IconAndTextRow( + icon: @Composable () -> Unit, + text: @Composable () -> Unit, + opacityProgress: () -> Float, // Lambdas instead of Floats, to defer read + widthProgress: () -> Float, + modifier: Modifier, +) { + Layout( + modifier = modifier, + content = { + icon() + Box(modifier = Modifier.graphicsLayer { alpha = opacityProgress() }) { + text() + } + }, + ) { measurables, constraints -> + + val iconPlaceable = measurables[0].measure(constraints) + val textPlaceable = measurables[1].measure(constraints) + + val height = constraints.maxHeight + + // FAB has an aspect ratio of 1 so the initial width is the height + val initialWidth = height.toFloat() + + // Use it to get the padding + val iconPadding = (initialWidth - iconPlaceable.width) / 2f + + // The full width will be : padding + icon + padding + text + padding + val expandedWidth = iconPlaceable.width + textPlaceable.width + iconPadding * 3 + + // Apply the animation factor to go from initialWidth to fullWidth + val width = lerp(initialWidth, expandedWidth, widthProgress()) + + layout(width.roundToInt(), height) { + iconPlaceable.place( + iconPadding.roundToInt(), + constraints.maxHeight / 2 - iconPlaceable.height / 2, + ) + textPlaceable.place( + (iconPlaceable.width + iconPadding * 2).roundToInt(), + constraints.maxHeight / 2 - textPlaceable.height / 2, + ) + } + } +} + +private enum class ExpandableFabStates { Collapsed, Extended } + +private const val transitionDuration = 200 diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/components/BaseLineHeightModifier.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/BaseLineHeightModifier.kt new file mode 100644 index 0000000..3b19c5a --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/BaseLineHeightModifier.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.components + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp + +/** + * Applied to a Text, it sets the distance between the top and the first baseline. It + * also makes the bottom of the element coincide with the last baseline of the text. + * + * _______________ + * | | ↑ + * | | | heightFromBaseline + * |Hello, World!| ↓ + * ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ + * + * This modifier can be used to distribute multiple text elements using a certain distance between + * baselines. + */ +data class BaselineHeightModifier( + val heightFromBaseline: Dp, +) : LayoutModifier { + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val textPlaceable = measurable.measure(constraints) + val firstBaseline = textPlaceable[FirstBaseline] + val lastBaseline = textPlaceable[LastBaseline] + + val height = heightFromBaseline.roundToPx() + lastBaseline - firstBaseline + return layout(constraints.maxWidth, height) { + val topY = heightFromBaseline.roundToPx() - firstBaseline + textPlaceable.place(0, topY) + } + } +} + +fun Modifier.baselineHeight(heightFromBaseline: Dp): Modifier = this.then(BaselineHeightModifier(heightFromBaseline)) diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatAppBar.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatAppBar.kt new file mode 100644 index 0000000..ffd74d0 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatAppBar.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package xyz.magicalbits.smsremote.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import xyz.magicalbits.smsremote.R +import xyz.magicalbits.smsremote.theme.JetchatTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JetchatAppBar( + modifier: Modifier = Modifier, + scrollBehavior: TopAppBarScrollBehavior? = null, + onNavIconPressed: () -> Unit = { }, + title: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit = {}, +) { + CenterAlignedTopAppBar( + modifier = modifier, + actions = actions, + title = title, + scrollBehavior = scrollBehavior, + navigationIcon = { + JetchatIcon( + contentDescription = stringResource(id = R.string.navigation_drawer_open), + modifier = Modifier + .size(64.dp) + .clickable(onClick = onNavIconPressed) + .padding(16.dp), + ) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun JetchatAppBarPreview() { + JetchatTheme { + JetchatAppBar(title = { Text("Preview!") }) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun JetchatAppBarPreviewDark() { + JetchatTheme(isDarkTheme = true) { + JetchatAppBar(title = { Text("Preview!") }) + } +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatDrawer.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatDrawer.kt new file mode 100644 index 0000000..d46e26a --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatDrawer.kt @@ -0,0 +1,266 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.components + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterStart +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import xyz.magicalbits.smsremote.R +import xyz.magicalbits.smsremote.theme.JetchatTheme +import xyz.magicalbits.smsremote.widget.WidgetReceiver + +@Composable +fun JetchatDrawerContent(onChatClicked: (String) -> Unit, selectedMenu: String = "iPhone XYZ") { + // Use windowInsetsTopHeight() to add a spacer which pushes the drawer content + // below the status bar (y-axis) + Column { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) + DrawerHeader() + DividerItem() + DrawerItemHeader("Devices") + DeviceItem("Samsung A14", selectedMenu == "Samsung A14") { + onChatClicked("Samsung A14") + } + DeviceItem("iPhone XYZ", selectedMenu == "iPhone XYZ") { + onChatClicked("iPhone XYZ") + } +// DividerItem(modifier = Modifier.padding(horizontal = 28.dp)) + if (widgetAddingIsSupported(LocalContext.current)) { + DividerItem(modifier = Modifier.padding(horizontal = 28.dp)) + DrawerItemHeader("Settings") + WidgetDiscoverability() + } + } +} + +@Composable +private fun DrawerHeader() { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = CenterVertically) { + JetchatIcon( + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Image( + painter = painterResource(id = R.drawable.jetchat_logo), + contentDescription = null, + modifier = Modifier.padding(start = 8.dp), + ) + } +} + +@Composable +private fun DrawerItemHeader(text: String) { + Box( + modifier = Modifier + .heightIn(min = 52.dp) + .padding(horizontal = 28.dp), + contentAlignment = CenterStart, + ) { + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun DeviceItem(text: String, selected: Boolean, onChatClicked: () -> Unit) { + val background = if (selected) { + Modifier.background(MaterialTheme.colorScheme.primaryContainer) + } else { + Modifier + } + Row( + modifier = Modifier + .height(56.dp) + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(CircleShape) + .then(background) + .clickable(onClick = onChatClicked), + verticalAlignment = CenterVertically, + ) { + val iconTint = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Icon( + painter = painterResource(id = R.drawable.ic_jetchat), + tint = iconTint, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 16.dp), + contentDescription = null, + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.padding(start = 12.dp), + ) + } +} + +@Composable +private fun ProfileItem(text: String, @DrawableRes profilePic: Int?, selected: Boolean = false, onProfileClicked: () -> Unit) { + val background = if (selected) { + Modifier.background(MaterialTheme.colorScheme.primaryContainer) + } else { + Modifier + } + Row( + modifier = Modifier + .height(56.dp) + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(CircleShape) + .then(background) + .clickable(onClick = onProfileClicked), + verticalAlignment = CenterVertically, + ) { + val paddingSizeModifier = Modifier + .padding(start = 16.dp, top = 16.dp, bottom = 16.dp) + .size(24.dp) + if (profilePic != null) { + Image( + painter = painterResource(id = profilePic), + modifier = paddingSizeModifier.then(Modifier.clip(CircleShape)), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } else { + Spacer(modifier = paddingSizeModifier) + } + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 12.dp), + ) + } +} + +@Composable +fun DividerItem(modifier: Modifier = Modifier) { + HorizontalDivider( + modifier = modifier, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + ) +} + +@Composable +@Preview +fun DrawerPreview() { + JetchatTheme { + Surface { + Column { + JetchatDrawerContent({}) + } + } + } +} + +@Composable +@Preview +fun DrawerPreviewDark() { + JetchatTheme(isDarkTheme = true) { + Surface { + Column { + JetchatDrawerContent({}) + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun WidgetDiscoverability() { + val context = LocalContext.current + Row( + modifier = Modifier + .height(56.dp) + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(CircleShape) + .clickable(onClick = { + addWidgetToHomeScreen(context) + }), + verticalAlignment = CenterVertically, + ) { + Text( + stringResource(id = R.string.add_widget_to_home_page), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 12.dp), + ) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun addWidgetToHomeScreen(context: Context) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val myProvider = ComponentName(context, WidgetReceiver::class.java) + if (widgetAddingIsSupported(context)) { + appWidgetManager.requestPinAppWidget(myProvider, null, null) + } +} + +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) +private fun widgetAddingIsSupported(context: Context): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + AppWidgetManager.getInstance(context).isRequestPinAppWidgetSupported +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatIcon.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatIcon.kt new file mode 100644 index 0000000..f874467 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatIcon.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import xyz.magicalbits.smsremote.R + +@Composable +fun JetchatIcon(contentDescription: String?, modifier: Modifier = Modifier) { + val semantics = if (contentDescription != null) { + Modifier.semantics { + this.contentDescription = contentDescription + this.role = Role.Image + } + } else { + Modifier + } + Box(modifier = modifier.then(semantics)) { + Icon( + painter = painterResource(id = R.drawable.ic_jetchat_back), + contentDescription = null, + tint = MaterialTheme.colorScheme.primaryContainer, + ) + Icon( + painter = painterResource(id = R.drawable.ic_jetchat_front), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatScaffold.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatScaffold.kt new file mode 100644 index 0000000..257ab78 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/components/JetchatScaffold.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.components + +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue.Closed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import xyz.magicalbits.smsremote.theme.JetchatTheme + +@Composable +fun JetchatDrawer( + drawerState: DrawerState = rememberDrawerState(initialValue = Closed), + selectedMenu: String, + onChatClicked: (String) -> Unit, + content: @Composable () -> Unit, +) { + JetchatTheme { + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet( + drawerState = drawerState, + drawerContainerColor = MaterialTheme.colorScheme.background, + drawerContentColor = MaterialTheme.colorScheme.onBackground, + ) { + JetchatDrawerContent( + onChatClicked = onChatClicked, + selectedMenu = selectedMenu, + ) + } + }, + content = content, + ) + } +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/Conversation.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/Conversation.kt new file mode 100644 index 0000000..6ac55b9 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/Conversation.kt @@ -0,0 +1,563 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package xyz.magicalbits.smsremote.conversation + +import android.content.ClipDescription +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.paddingFrom +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.mimeTypes +import androidx.compose.ui.draganddrop.toAndroidDragEvent +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup +import xyz.magicalbits.smsremote.R +import xyz.magicalbits.smsremote.components.JetchatAppBar +import xyz.magicalbits.smsremote.data.exampleUiState +import xyz.magicalbits.smsremote.theme.JetchatTheme +import kotlinx.coroutines.launch + +/** + * Entry point for a conversation screen. + * + * @param uiState [ConversationUiState] that contains messages to display + * @param navigateToProfile User action when navigation to a profile is requested + * @param modifier [Modifier] to apply to this layout node + * @param onNavIconPressed Sends an event up when the user clicks on the menu + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ConversationContent( + uiState: ConversationUiState, + navigateToProfile: (String) -> Unit, + modifier: Modifier = Modifier, + onNavIconPressed: () -> Unit = { }, +) { + val authorMe = stringResource(R.string.author_me) + val timeNow = stringResource(id = R.string.now) + + val scrollState = rememberLazyListState() + val topBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState) + val scope = rememberCoroutineScope() + + var background by remember { + mutableStateOf(Color.Transparent) + } + + var borderStroke by remember { + mutableStateOf(Color.Transparent) + } + + val dragAndDropCallback = remember { + object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + val clipData = event.toAndroidDragEvent().clipData + + if (clipData.itemCount < 1) { + return false + } + + uiState.addMessage( + Message(authorMe, clipData.getItemAt(0).text.toString(), timeNow), + ) + + return true + } + + override fun onStarted(event: DragAndDropEvent) { + super.onStarted(event) + borderStroke = Color.Red + } + + override fun onEntered(event: DragAndDropEvent) { + super.onEntered(event) + background = Color.Red.copy(alpha = .3f) + } + + override fun onExited(event: DragAndDropEvent) { + super.onExited(event) + background = Color.Transparent + } + + override fun onEnded(event: DragAndDropEvent) { + super.onEnded(event) + background = Color.Transparent + borderStroke = Color.Transparent + } + } + } + + Scaffold( + topBar = { + ChannelNameBar( + channelName = uiState.channelName, + channelMembers = uiState.channelMembers, + onNavIconPressed = onNavIconPressed, + scrollBehavior = scrollBehavior, + ) + }, + // Exclude ime and navigation bar padding so this can be added by the UserInput composable + contentWindowInsets = ScaffoldDefaults + .contentWindowInsets + .exclude(WindowInsets.navigationBars) + .exclude(WindowInsets.ime), + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { paddingValues -> + Column( + Modifier.fillMaxSize().padding(paddingValues) + .background(color = background) + .border(width = 2.dp, color = borderStroke) + .dragAndDropTarget(shouldStartDragAndDrop = { event -> + event + .mimeTypes() + .contains( + ClipDescription.MIMETYPE_TEXT_PLAIN, + ) + }, target = dragAndDropCallback), + ) { + Messages( + messages = uiState.messages, + navigateToProfile = navigateToProfile, + modifier = Modifier.weight(1f), + scrollState = scrollState, + ) + UserInput( + onMessageSent = { content -> + uiState.addMessage( + Message(authorMe, content, timeNow), + ) + }, + resetScroll = { + scope.launch { + scrollState.scrollToItem(0) + } + }, + // let this element handle the padding so that the elevation is shown behind the + // navigation bar + modifier = Modifier.navigationBarsPadding().imePadding(), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChannelNameBar( + channelName: String, + channelMembers: Int, + modifier: Modifier = Modifier, + scrollBehavior: TopAppBarScrollBehavior? = null, + onNavIconPressed: () -> Unit = { }, +) { + var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } + if (functionalityNotAvailablePopupShown) { + FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } + } + JetchatAppBar( + modifier = modifier, + scrollBehavior = scrollBehavior, + onNavIconPressed = onNavIconPressed, + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // Channel name + Text( + text = channelName, + style = MaterialTheme.typography.titleMedium, + ) + // Number of members + Text( + text = stringResource(R.string.members, channelMembers), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + actions = { + // Search icon + Icon( + painterResource(id = R.drawable.ic_search), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clickable(onClick = { functionalityNotAvailablePopupShown = true }) + .padding(horizontal = 12.dp, vertical = 16.dp) + .height(24.dp), + contentDescription = stringResource(id = R.string.search), + ) + // Info icon + Icon( + painterResource(id = R.drawable.ic_info), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clickable(onClick = { functionalityNotAvailablePopupShown = true }) + .padding(horizontal = 12.dp, vertical = 16.dp) + .height(24.dp), + contentDescription = stringResource(id = R.string.info), + ) + }, + ) +} + +const val ConversationTestTag = "ConversationTestTag" + +@Composable +fun Messages(messages: List, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier) { + val scope = rememberCoroutineScope() + Box(modifier = modifier) { + + val authorMe = stringResource(id = R.string.author_me) + LazyColumn( + reverseLayout = true, + state = scrollState, + modifier = Modifier + .testTag(ConversationTestTag) + .fillMaxSize(), + ) { + for (index in messages.indices) { + val prevAuthor = messages.getOrNull(index - 1)?.author + val nextAuthor = messages.getOrNull(index + 1)?.author + val content = messages[index] + val isFirstMessageByAuthor = prevAuthor != content.author + val isLastMessageByAuthor = nextAuthor != content.author + + // Hardcode day dividers for simplicity + if (index == messages.size - 1) { + item { + DayHeader("20 Aug") + } + } else if (index == 2) { + item { + DayHeader("Today") + } + } + + item { + Message( + onAuthorClick = { name -> navigateToProfile(name) }, + msg = content, + isUserMe = content.author == authorMe, + isFirstMessageByAuthor = isFirstMessageByAuthor, + isLastMessageByAuthor = isLastMessageByAuthor, + ) + } + } + } + // Jump to bottom button shows up when user scrolls past a threshold. + // Convert to pixels: + val jumpThreshold = with(LocalDensity.current) { + JumpToBottomThreshold.toPx() + } + + // Show the button if the first visible item is not the first one or if the offset is + // greater than the threshold. + val jumpToBottomButtonEnabled by remember { + derivedStateOf { + scrollState.firstVisibleItemIndex != 0 || + scrollState.firstVisibleItemScrollOffset > jumpThreshold + } + } + + JumpToBottom( + // Only show if the scroller is not at the bottom + enabled = jumpToBottomButtonEnabled, + onClicked = { + scope.launch { + scrollState.animateScrollToItem(0) + } + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } +} + +@Composable +fun Message( + onAuthorClick: (String) -> Unit, + msg: Message, + isUserMe: Boolean, + isFirstMessageByAuthor: Boolean, + isLastMessageByAuthor: Boolean, +) { + val borderColor = if (isUserMe) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.tertiary + } + + val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier + Row(modifier = spaceBetweenAuthors) { + if (isLastMessageByAuthor) { + // Avatar + Image( + modifier = Modifier + .clickable(onClick = { onAuthorClick(msg.author) }) + .padding(horizontal = 16.dp) + .size(42.dp) + .border(1.5.dp, borderColor, CircleShape) + .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape) + .clip(CircleShape) + .align(Alignment.Top), + painter = painterResource(id = msg.authorImage), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } else { + // Space under avatar + Spacer(modifier = Modifier.width(74.dp)) + } + AuthorAndTextMessage( + msg = msg, + isUserMe = isUserMe, + isFirstMessageByAuthor = isFirstMessageByAuthor, + isLastMessageByAuthor = isLastMessageByAuthor, + authorClicked = onAuthorClick, + modifier = Modifier + .padding(end = 16.dp) + .weight(1f), + ) + } +} + +@Composable +fun AuthorAndTextMessage( + msg: Message, + isUserMe: Boolean, + isFirstMessageByAuthor: Boolean, + isLastMessageByAuthor: Boolean, + authorClicked: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + if (isLastMessageByAuthor) { + AuthorNameTimestamp(msg) + } + ChatItemBubble(msg, isUserMe, authorClicked = authorClicked) + if (isFirstMessageByAuthor) { + // Last bubble before next author + Spacer(modifier = Modifier.height(8.dp)) + } else { + // Between bubbles + Spacer(modifier = Modifier.height(4.dp)) + } + } +} + +@Composable +private fun AuthorNameTimestamp(msg: Message) { + // Combine author and timestamp for a11y. + Row(modifier = Modifier.semantics(mergeDescendants = true) {}) { + Text( + text = msg.author, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .alignBy(LastBaseline) + .paddingFrom(LastBaseline, after = 8.dp), // Space to 1st bubble + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = msg.timestamp, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alignBy(LastBaseline), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp) + +@Composable +fun DayHeader(dayString: String) { + Row( + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 16.dp) + .height(16.dp), + ) { + DayHeaderLine() + Text( + text = dayString, + modifier = Modifier.padding(horizontal = 16.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + DayHeaderLine() + } +} + +@Composable +private fun RowScope.DayHeaderLine() { + HorizontalDivider( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + ) +} + +@Composable +fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) { + + val backgroundBubbleColor = if (isUserMe) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + + Column { + Surface( + color = backgroundBubbleColor, + shape = ChatBubbleShape, + ) { + ClickableMessage( + message = message, + isUserMe = isUserMe, + authorClicked = authorClicked, + ) + } + + message.image?.let { + Spacer(modifier = Modifier.height(4.dp)) + Surface( + color = backgroundBubbleColor, + shape = ChatBubbleShape, + ) { + Image( + painter = painterResource(it), + contentScale = ContentScale.Fit, + modifier = Modifier.size(160.dp), + contentDescription = stringResource(id = R.string.attached_image), + ) + } + } + } +} + +@Composable +fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) { + val uriHandler = LocalUriHandler.current + + val styledMessage = messageFormatter( + text = message.content, + primary = isUserMe, + ) + + ClickableText( + text = styledMessage, + style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current), + modifier = Modifier.padding(16.dp), + onClick = { + styledMessage + .getStringAnnotations(start = it, end = it) + .firstOrNull() + ?.let { annotation -> + when (annotation.tag) { + SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item) + SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item) + else -> Unit + } + } + }, + ) +} + +@Preview +@Composable +fun ConversationPreview() { + JetchatTheme { + ConversationContent( + uiState = exampleUiState, + navigateToProfile = { }, + ) + } +} + +@Preview +@Composable +fun ChannelBarPrev() { + JetchatTheme { + ChannelNameBar(channelName = "composers", channelMembers = 52) + } +} + +@Preview +@Composable +fun DayHeaderPrev() { + DayHeader("Aug 6") +} + +private val JumpToBottomThreshold = 56.dp diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationFragment.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationFragment.kt new file mode 100644 index 0000000..b946e66 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationFragment.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.conversation + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import androidx.compose.ui.platform.ComposeView +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import xyz.magicalbits.smsremote.MainViewModel +import xyz.magicalbits.smsremote.R +import xyz.magicalbits.smsremote.data.exampleUiState +import xyz.magicalbits.smsremote.data.exampleUiState2 +import xyz.magicalbits.smsremote.theme.JetchatTheme + +class ConversationFragment : Fragment() { + private val activityViewModel: MainViewModel by activityViewModels() + + var phoneNumber: String = "" + + override fun onAttach(context: Context) { + super.onAttach(context) + // Consider using safe args plugin + val phoneNumber = arguments?.getString("phoneNumber") +// viewModel.setDeviceId(deviceId) + this.phoneNumber = phoneNumber!! + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(inflater.context).apply { + layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) + + val uiState = + if (phoneNumber == "+420123456789") { + exampleUiState + } else { + exampleUiState2 + } + uiState.channelName = phoneNumber + + setContent { + JetchatTheme { + ConversationContent( + uiState = uiState, + navigateToProfile = { user -> + // Click callback + val bundle = bundleOf("userId" to user) + findNavController().navigate( + R.id.nav_profile, + bundle, + ) + }, + onNavIconPressed = { + activityViewModel.openDrawer() + }, + ) + } + } + } +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationUiState.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationUiState.kt new file mode 100644 index 0000000..64cf2ec --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/ConversationUiState.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.conversation + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.toMutableStateList +import xyz.magicalbits.smsremote.R + +class ConversationUiState( + var channelName: String, + val channelMembers: Int, + initialMessages: List, +) { + private val _messages: MutableList = initialMessages.toMutableStateList() + val messages: List = _messages + + fun addMessage(msg: Message) { + _messages.add(0, msg) // Add to the beginning of the list + } +} + +@Immutable +data class Message( + val author: String, + val content: String, + val timestamp: String, + val image: Int? = null, + val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else, +) diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/JumpToBottom.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/JumpToBottom.kt new file mode 100644 index 0000000..0e4ff9a --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/JumpToBottom.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.conversation + +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import xyz.magicalbits.smsremote.R + +private enum class Visibility { + VISIBLE, + GONE, +} + +/** + * Shows a button that lets the user scroll to the bottom. + */ +@Composable +fun JumpToBottom(enabled: Boolean, onClicked: () -> Unit, modifier: Modifier = Modifier) { + // Show Jump to Bottom button + val transition = updateTransition( + if (enabled) Visibility.VISIBLE else Visibility.GONE, + label = "JumpToBottom visibility animation", + ) + val bottomOffset by transition.animateDp(label = "JumpToBottom offset animation") { + if (it == Visibility.GONE) { + (-32).dp + } else { + 32.dp + } + } + if (bottomOffset > 0.dp) { + ExtendedFloatingActionButton( + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_downward), + modifier = Modifier.height(18.dp), + contentDescription = null, + ) + }, + text = { + Text(text = stringResource(id = R.string.jumpBottom)) + }, + onClick = onClicked, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, + modifier = modifier + .offset(x = 0.dp, y = -bottomOffset) + .height(36.dp), + ) + } +} + +@Preview +@Composable +fun JumpToBottomPreview() { + JumpToBottom(enabled = true, onClicked = {}) +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/MessageFormatter.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/MessageFormatter.kt new file mode 100644 index 0000000..9c2adb3 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/MessageFormatter.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.conversation + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.sp + +// Regex containing the syntax tokens +val symbolPattern by lazy { + Regex("""(https?://[^\s\t\n]+)|(`[^`]+`)|(@\w+)|(\*[\w]+\*)|(_[\w]+_)|(~[\w]+~)""") +} + +// Accepted annotations for the ClickableTextWrapper +enum class SymbolAnnotationType { + PERSON, + LINK, +} +typealias StringAnnotation = AnnotatedString.Range + +// Pair returning styled content and annotation for ClickableText when matching syntax token +typealias SymbolAnnotation = Pair + +/** + * Format a message following Markdown-lite syntax + * | @username -> bold, primary color and clickable element + * | http(s)://... -> clickable link, opening it into the browser + * | *bold* -> bold + * | _italic_ -> italic + * | ~strikethrough~ -> strikethrough + * | `MyClass.myMethod` -> inline code styling + * + * @param text contains message to be parsed + * @return AnnotatedString with annotations used inside the ClickableText wrapper + */ +@Composable +fun messageFormatter( + text: String, + primary: Boolean, +): AnnotatedString { + val tokens = symbolPattern.findAll(text) + + return buildAnnotatedString { + var cursorPosition = 0 + + val codeSnippetBackground = + if (primary) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.surface + } + + for (token in tokens) { + append(text.slice(cursorPosition until token.range.first)) + + val (annotatedString, stringAnnotation) = + getSymbolAnnotation( + matchResult = token, + colorScheme = MaterialTheme.colorScheme, + primary = primary, + codeSnippetBackground = codeSnippetBackground, + ) + append(annotatedString) + + if (stringAnnotation != null) { + val (item, start, end, tag) = stringAnnotation + addStringAnnotation(tag = tag, start = start, end = end, annotation = item) + } + + cursorPosition = token.range.last + 1 + } + + if (!tokens.none()) { + append(text.slice(cursorPosition..text.lastIndex)) + } else { + append(text) + } + } +} + +/** + * Map regex matches found in a message with supported syntax symbols + * + * @param matchResult is a regex result matching our syntax symbols + * @return pair of AnnotatedString with annotation (optional) used inside the ClickableText wrapper + */ +private fun getSymbolAnnotation( + matchResult: MatchResult, + colorScheme: ColorScheme, + primary: Boolean, + codeSnippetBackground: Color, +): SymbolAnnotation = + when (matchResult.value.first()) { + '@' -> { + SymbolAnnotation( + AnnotatedString( + text = matchResult.value, + spanStyle = + SpanStyle( + color = if (primary) colorScheme.inversePrimary else colorScheme.primary, + fontWeight = FontWeight.Bold, + ), + ), + StringAnnotation( + item = matchResult.value.substring(1), + start = matchResult.range.first, + end = matchResult.range.last, + tag = SymbolAnnotationType.PERSON.name, + ), + ) + } + + '*' -> { + SymbolAnnotation( + AnnotatedString( + text = matchResult.value.trim('*'), + spanStyle = SpanStyle(fontWeight = FontWeight.Bold), + ), + null, + ) + } + + '_' -> { + SymbolAnnotation( + AnnotatedString( + text = matchResult.value.trim('_'), + spanStyle = SpanStyle(fontStyle = FontStyle.Italic), + ), + null, + ) + } + + '~' -> { + SymbolAnnotation( + AnnotatedString( + text = matchResult.value.trim('~'), + spanStyle = SpanStyle(textDecoration = TextDecoration.LineThrough), + ), + null, + ) + } + + '`' -> { + SymbolAnnotation( + AnnotatedString( + text = matchResult.value.trim('`'), + spanStyle = + SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + background = codeSnippetBackground, + baselineShift = BaselineShift(0.2f), + ), + ), + null, + ) + } + + 'h' -> { + SymbolAnnotation( + AnnotatedString( + text = matchResult.value, + spanStyle = + SpanStyle( + color = if (primary) colorScheme.inversePrimary else colorScheme.primary, + ), + ), + StringAnnotation( + item = matchResult.value, + start = matchResult.range.first, + end = matchResult.range.last, + tag = SymbolAnnotationType.LINK.name, + ), + ) + } + + else -> { + SymbolAnnotation(AnnotatedString(matchResult.value), null) + } + } diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/UserInput.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/UserInput.kt new file mode 100644 index 0000000..e3c5a07 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/conversation/UserInput.kt @@ -0,0 +1,772 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.conversation + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.paddingFrom +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup +import xyz.magicalbits.smsremote.R +import kotlin.math.absoluteValue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay + +enum class InputSelector { + NONE, + MAP, + DM, + EMOJI, + PHONE, + PICTURE, +} + +enum class EmojiStickerSelector { + EMOJI, + STICKER, +} + +@Preview +@Composable +fun UserInputPreview() { + UserInput(onMessageSent = {}) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun UserInput(onMessageSent: (String) -> Unit, modifier: Modifier = Modifier, resetScroll: () -> Unit = {}) { + var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) } + val dismissKeyboard = { currentInputSelector = InputSelector.NONE } + + // Intercept back navigation if there's a InputSelector visible + if (currentInputSelector != InputSelector.NONE) { + BackHandler(onBack = dismissKeyboard) + } + + var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + + // Used to decide if the keyboard should be shown + var textFieldFocusState by remember { mutableStateOf(false) } + + Surface(tonalElevation = 2.dp, contentColor = MaterialTheme.colorScheme.secondary) { + Column(modifier = modifier) { + UserInputText( + textFieldValue = textState, + onTextChanged = { textState = it }, + // Only show the keyboard if there's no input selector and text field has focus + keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState, + // Close extended selector if text field receives focus + onTextFieldFocused = { focused -> + if (focused) { + currentInputSelector = InputSelector.NONE + resetScroll() + } + textFieldFocusState = focused + }, + onMessageSent = { + onMessageSent(textState.text) + // Reset text field and close keyboard + textState = TextFieldValue() + // Move scroll to bottom + resetScroll() + }, + focusState = textFieldFocusState, + ) + UserInputSelector( + onSelectorChange = { currentInputSelector = it }, + sendMessageEnabled = textState.text.isNotBlank(), + onMessageSent = { + onMessageSent(textState.text) + // Reset text field and close keyboard + textState = TextFieldValue() + // Move scroll to bottom + resetScroll() + dismissKeyboard() + }, + currentInputSelector = currentInputSelector, + ) + SelectorExpanded( + onCloseRequested = dismissKeyboard, + onTextAdded = { textState = textState.addText(it) }, + currentSelector = currentInputSelector, + ) + } + } +} + +private fun TextFieldValue.addText(newString: String): TextFieldValue { + val newText = this.text.replaceRange( + this.selection.start, + this.selection.end, + newString, + ) + val newSelection = TextRange( + start = newText.length, + end = newText.length, + ) + + return this.copy(text = newText, selection = newSelection) +} + +@Composable +private fun SelectorExpanded(currentSelector: InputSelector, onCloseRequested: () -> Unit, onTextAdded: (String) -> Unit) { + if (currentSelector == InputSelector.NONE) return + + // Request focus to force the TextField to lose it + val focusRequester = remember { FocusRequester() } + // If the selector is shown, always request focus to trigger a TextField.onFocusChange. + SideEffect { + if (currentSelector == InputSelector.EMOJI) { + focusRequester.requestFocus() + } + } + + Surface(tonalElevation = 8.dp) { + when (currentSelector) { + InputSelector.EMOJI -> EmojiSelector(onTextAdded, focusRequester) + InputSelector.DM -> NotAvailablePopup(onCloseRequested) + InputSelector.PICTURE -> FunctionalityNotAvailablePanel() + InputSelector.MAP -> FunctionalityNotAvailablePanel() + InputSelector.PHONE -> FunctionalityNotAvailablePanel() + InputSelector.NONE -> Unit + } + } +} + +@Composable +fun FunctionalityNotAvailablePanel() { + AnimatedVisibility( + visibleState = remember { MutableTransitionState(false).apply { targetState = true } }, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut(), + ) { + Column( + modifier = Modifier + .height(320.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(id = R.string.not_available), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(id = R.string.not_available_subtitle), + modifier = Modifier.paddingFrom(FirstBaseline, before = 32.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun UserInputSelector( + onSelectorChange: (InputSelector) -> Unit, + sendMessageEnabled: Boolean, + onMessageSent: () -> Unit, + currentInputSelector: InputSelector, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .height(72.dp) + .wrapContentHeight() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + InputSelectorButton( + onClick = { onSelectorChange(InputSelector.EMOJI) }, + icon = painterResource(id = R.drawable.ic_mood), + selected = currentInputSelector == InputSelector.EMOJI, + description = stringResource(id = R.string.emoji_selector_bt_desc), + ) + InputSelectorButton( + onClick = { onSelectorChange(InputSelector.DM) }, + icon = painterResource(id = R.drawable.ic_alternate_email), + selected = currentInputSelector == InputSelector.DM, + description = stringResource(id = R.string.dm_desc), + ) + InputSelectorButton( + onClick = { onSelectorChange(InputSelector.PICTURE) }, + icon = painterResource(id = R.drawable.ic_insert_photo), + selected = currentInputSelector == InputSelector.PICTURE, + description = stringResource(id = R.string.attach_photo_desc), + ) + InputSelectorButton( + onClick = { onSelectorChange(InputSelector.MAP) }, + icon = painterResource(id = R.drawable.ic_place), + selected = currentInputSelector == InputSelector.MAP, + description = stringResource(id = R.string.map_selector_desc), + ) + InputSelectorButton( + onClick = { onSelectorChange(InputSelector.PHONE) }, + icon = painterResource(id = R.drawable.ic_duo), + selected = currentInputSelector == InputSelector.PHONE, + description = stringResource(id = R.string.videochat_desc), + ) + + val border = if (!sendMessageEnabled) { + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), + ) + } else { + null + } + Spacer(modifier = Modifier.weight(1f)) + + val disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + + val buttonColors = ButtonDefaults.buttonColors( + disabledContainerColor = Color.Transparent, + disabledContentColor = disabledContentColor, + ) + + // Send button + Button( + modifier = Modifier.height(36.dp), + enabled = sendMessageEnabled, + onClick = onMessageSent, + colors = buttonColors, + border = border, + contentPadding = PaddingValues(0.dp), + ) { + Text( + stringResource(id = R.string.send), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } +} + +@Composable +private fun InputSelectorButton( + onClick: () -> Unit, + icon: androidx.compose.ui.graphics.painter.Painter, + description: String, + selected: Boolean, + modifier: Modifier = Modifier, +) { + val backgroundModifier = if (selected) { + Modifier.background( + color = LocalContentColor.current, + shape = RoundedCornerShape(14.dp), + ) + } else { + Modifier + } + IconButton( + onClick = onClick, + modifier = modifier.then(backgroundModifier), + ) { + val tint = if (selected) { + contentColorFor(backgroundColor = LocalContentColor.current) + } else { + LocalContentColor.current + } + Icon( + icon, + tint = tint, + modifier = Modifier + .padding(8.dp) + .size(56.dp), + contentDescription = description, + ) + } +} + +@Composable +private fun NotAvailablePopup(onDismissed: () -> Unit) { + FunctionalityNotAvailablePopup(onDismissed) +} + +val KeyboardShownKey = SemanticsPropertyKey("KeyboardShownKey") +var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey + +@OptIn(ExperimentalAnimationApi::class) +@ExperimentalFoundationApi +@Composable +private fun UserInputText( + keyboardType: KeyboardType = KeyboardType.Text, + onTextChanged: (TextFieldValue) -> Unit, + textFieldValue: TextFieldValue, + keyboardShown: Boolean, + onTextFieldFocused: (Boolean) -> Unit, + onMessageSent: (String) -> Unit, + focusState: Boolean, +) { + val swipeOffset = remember { mutableStateOf(0f) } + var isRecordingMessage by remember { mutableStateOf(false) } + val a11ylabel = stringResource(id = R.string.textfield_desc) + Row( + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + horizontalArrangement = Arrangement.End, + ) { + AnimatedContent( + targetState = isRecordingMessage, + label = "text-field", + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) { recording -> + Box(Modifier.fillMaxSize()) { + if (recording) { + RecordingIndicator { swipeOffset.value } + } else { + UserInputTextField( + textFieldValue, + onTextChanged, + onTextFieldFocused, + keyboardType, + focusState, + onMessageSent, + Modifier.fillMaxWidth().semantics { + contentDescription = a11ylabel + keyboardShownProperty = keyboardShown + }, + ) + } + } + } + } +} + +@Composable +private fun BoxScope.UserInputTextField( + textFieldValue: TextFieldValue, + onTextChanged: (TextFieldValue) -> Unit, + onTextFieldFocused: (Boolean) -> Unit, + keyboardType: KeyboardType, + focusState: Boolean, + onMessageSent: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var lastFocusState by remember { mutableStateOf(false) } + BasicTextField( + value = textFieldValue, + onValueChange = { onTextChanged(it) }, + modifier = modifier + .padding(start = 32.dp) + .align(Alignment.CenterStart) + .onFocusChanged { state -> + if (lastFocusState != state.isFocused) { + onTextFieldFocused(state.isFocused) + } + lastFocusState = state.isFocused + }, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Send, + ), + keyboardActions = KeyboardActions { + if (textFieldValue.text.isNotBlank()) onMessageSent(textFieldValue.text) + }, + maxLines = 1, + cursorBrush = SolidColor(LocalContentColor.current), + textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current), + ) + + val disableContentColor = + MaterialTheme.colorScheme.onSurfaceVariant + if (textFieldValue.text.isEmpty() && !focusState) { + Text( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 32.dp), + text = stringResource(R.string.textfield_hint), + style = MaterialTheme.typography.bodyLarge.copy(color = disableContentColor), + ) + } +} + +@Composable +private fun RecordingIndicator(swipeOffset: () -> Float) { + var duration by remember { mutableStateOf(Duration.ZERO) } + LaunchedEffect(Unit) { + while (true) { + delay(1000) + duration += 1.seconds + } + } + Row( + Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + + val animatedPulse = infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.2f, + animationSpec = infiniteRepeatable( + tween(2000), + repeatMode = RepeatMode.Reverse, + ), + label = "pulse", + ) + Box( + Modifier + .size(56.dp) + .padding(24.dp) + .graphicsLayer { + scaleX = animatedPulse.value + scaleY = animatedPulse.value + } + .clip(CircleShape) + .background(Color.Red), + ) + Text( + duration.toComponents { minutes, seconds, _ -> + val min = minutes.toString().padStart(2, '0') + val sec = seconds.toString().padStart(2, '0') + "$min:$sec" + }, + Modifier.alignByBaseline(), + ) + Box( + Modifier + .fillMaxSize() + .alignByBaseline() + .clipToBounds(), + ) { + val swipeThreshold = with(LocalDensity.current) { 200.dp.toPx() } + Text( + modifier = Modifier + .align(Alignment.Center) + .graphicsLayer { + translationX = swipeOffset() / 2 + alpha = 1 - (swipeOffset().absoluteValue / swipeThreshold) + }, + textAlign = TextAlign.Center, + text = stringResource(R.string.swipe_to_cancel_recording), + style = MaterialTheme.typography.bodyLarge, + ) + } + } +} + +@Composable +fun EmojiSelector(onTextAdded: (String) -> Unit, focusRequester: FocusRequester) { + var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) } + + val a11yLabel = stringResource(id = R.string.emoji_selector_desc) + Column( + modifier = Modifier + .focusRequester(focusRequester) // Requests focus when the Emoji selector is displayed + // Make the emoji selector focusable so it can steal focus from TextField + .focusTarget() + .semantics { contentDescription = a11yLabel }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) { + ExtendedSelectorInnerButton( + text = stringResource(id = R.string.emojis_label), + onClick = { selected = EmojiStickerSelector.EMOJI }, + selected = true, + modifier = Modifier.weight(1f), + ) + ExtendedSelectorInnerButton( + text = stringResource(id = R.string.stickers_label), + onClick = { selected = EmojiStickerSelector.STICKER }, + selected = false, + modifier = Modifier.weight(1f), + ) + } + Row(modifier = Modifier.verticalScroll(rememberScrollState())) { + EmojiTable(onTextAdded, modifier = Modifier.padding(8.dp)) + } + } + if (selected == EmojiStickerSelector.STICKER) { + NotAvailablePopup(onDismissed = { selected = EmojiStickerSelector.EMOJI }) + } +} + +@Composable +fun ExtendedSelectorInnerButton(text: String, onClick: () -> Unit, selected: Boolean, modifier: Modifier = Modifier) { + val colors = ButtonDefaults.buttonColors( + containerColor = if (selected) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else Color.Transparent, + disabledContainerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.74f), + ) + TextButton( + onClick = onClick, + modifier = modifier + .padding(8.dp) + .height(36.dp), + colors = colors, + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + ) + } +} + +@Composable +fun EmojiTable(onTextAdded: (String) -> Unit, modifier: Modifier = Modifier) { + Column(modifier.fillMaxWidth()) { + repeat(4) { x -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + repeat(EMOJI_COLUMNS) { y -> + val emoji = emojis[x * EMOJI_COLUMNS + y] + Text( + modifier = Modifier + .clickable(onClick = { onTextAdded(emoji) }) + .sizeIn(minWidth = 42.dp, minHeight = 42.dp) + .padding(8.dp), + text = emoji, + style = LocalTextStyle.current.copy( + fontSize = 18.sp, + textAlign = TextAlign.Center, + ), + ) + } + } + } + } +} + +private const val EMOJI_COLUMNS = 10 + +private val emojis = listOf( + "\ud83d\ude00", // Grinning Face + "\ud83d\ude01", // Grinning Face With Smiling Eyes + "\ud83d\ude02", // Face With Tears of Joy + "\ud83d\ude03", // Smiling Face With Open Mouth + "\ud83d\ude04", // Smiling Face With Open Mouth and Smiling Eyes + "\ud83d\ude05", // Smiling Face With Open Mouth and Cold Sweat + "\ud83d\ude06", // Smiling Face With Open Mouth and Tightly-Closed Eyes + "\ud83d\ude09", // Winking Face + "\ud83d\ude0a", // Smiling Face With Smiling Eyes + "\ud83d\ude0b", // Face Savouring Delicious Food + "\ud83d\ude0e", // Smiling Face With Sunglasses + "\ud83d\ude0d", // Smiling Face With Heart-Shaped Eyes + "\ud83d\ude18", // Face Throwing a Kiss + "\ud83d\ude17", // Kissing Face + "\ud83d\ude19", // Kissing Face With Smiling Eyes + "\ud83d\ude1a", // Kissing Face With Closed Eyes + "\u263a", // White Smiling Face + "\ud83d\ude42", // Slightly Smiling Face + "\ud83e\udd17", // Hugging Face + "\ud83d\ude07", // Smiling Face With Halo + "\ud83e\udd13", // Nerd Face + "\ud83e\udd14", // Thinking Face + "\ud83d\ude10", // Neutral Face + "\ud83d\ude11", // Expressionless Face + "\ud83d\ude36", // Face Without Mouth + "\ud83d\ude44", // Face With Rolling Eyes + "\ud83d\ude0f", // Smirking Face + "\ud83d\ude23", // Persevering Face + "\ud83d\ude25", // Disappointed but Relieved Face + "\ud83d\ude2e", // Face With Open Mouth + "\ud83e\udd10", // Zipper-Mouth Face + "\ud83d\ude2f", // Hushed Face + "\ud83d\ude2a", // Sleepy Face + "\ud83d\ude2b", // Tired Face + "\ud83d\ude34", // Sleeping Face + "\ud83d\ude0c", // Relieved Face + "\ud83d\ude1b", // Face With Stuck-Out Tongue + "\ud83d\ude1c", // Face With Stuck-Out Tongue and Winking Eye + "\ud83d\ude1d", // Face With Stuck-Out Tongue and Tightly-Closed Eyes + "\ud83d\ude12", // Unamused Face + "\ud83d\ude13", // Face With Cold Sweat + "\ud83d\ude14", // Pensive Face + "\ud83d\ude15", // Confused Face + "\ud83d\ude43", // Upside-Down Face + "\ud83e\udd11", // Money-Mouth Face + "\ud83d\ude32", // Astonished Face + "\ud83d\ude37", // Face With Medical Mask + "\ud83e\udd12", // Face With Thermometer + "\ud83e\udd15", // Face With Head-Bandage + "\u2639", // White Frowning Face + "\ud83d\ude41", // Slightly Frowning Face + "\ud83d\ude16", // Confounded Face + "\ud83d\ude1e", // Disappointed Face + "\ud83d\ude1f", // Worried Face + "\ud83d\ude24", // Face With Look of Triumph + "\ud83d\ude22", // Crying Face + "\ud83d\ude2d", // Loudly Crying Face + "\ud83d\ude26", // Frowning Face With Open Mouth + "\ud83d\ude27", // Anguished Face + "\ud83d\ude28", // Fearful Face + "\ud83d\ude29", // Weary Face + "\ud83d\ude2c", // Grimacing Face + "\ud83d\ude30", // Face With Open Mouth and Cold Sweat + "\ud83d\ude31", // Face Screaming in Fear + "\ud83d\ude33", // Flushed Face + "\ud83d\ude35", // Dizzy Face + "\ud83d\ude21", // Pouting Face + "\ud83d\ude20", // Angry Face + "\ud83d\ude08", // Smiling Face With Horns + "\ud83d\udc7f", // Imp + "\ud83d\udc79", // Japanese Ogre + "\ud83d\udc7a", // Japanese Goblin + "\ud83d\udc80", // Skull + "\ud83d\udc7b", // Ghost + "\ud83d\udc7d", // Extraterrestrial Alien + "\ud83e\udd16", // Robot Face + "\ud83d\udca9", // Pile of Poo + "\ud83d\ude3a", // Smiling Cat Face With Open Mouth + "\ud83d\ude38", // Grinning Cat Face With Smiling Eyes + "\ud83d\ude39", // Cat Face With Tears of Joy + "\ud83d\ude3b", // Smiling Cat Face With Heart-Shaped Eyes + "\ud83d\ude3c", // Cat Face With Wry Smile + "\ud83d\ude3d", // Kissing Cat Face With Closed Eyes + "\ud83d\ude40", // Weary Cat Face + "\ud83d\ude3f", // Crying Cat Face + "\ud83d\ude3e", // Pouting Cat Face + "\ud83d\udc66", // Boy + "\ud83d\udc67", // Girl + "\ud83d\udc68", // Man + "\ud83d\udc69", // Woman + "\ud83d\udc74", // Older Man + "\ud83d\udc75", // Older Woman + "\ud83d\udc76", // Baby + "\ud83d\udc71", // Person With Blond Hair + "\ud83d\udc6e", // Police Officer + "\ud83d\udc72", // Man With Gua Pi Mao + "\ud83d\udc73", // Man With Turban + "\ud83d\udc77", // Construction Worker + "\u26d1", // Helmet With White Cross + "\ud83d\udc78", // Princess + "\ud83d\udc82", // Guardsman + "\ud83d\udd75", // Sleuth or Spy + "\ud83c\udf85", // Father Christmas + "\ud83d\udc70", // Bride With Veil + "\ud83d\udc7c", // Baby Angel + "\ud83d\udc86", // Face Massage + "\ud83d\udc87", // Haircut + "\ud83d\ude4d", // Person Frowning + "\ud83d\ude4e", // Person With Pouting Face + "\ud83d\ude45", // Face With No Good Gesture + "\ud83d\ude46", // Face With OK Gesture + "\ud83d\udc81", // Information Desk Person + "\ud83d\ude4b", // Happy Person Raising One Hand + "\ud83d\ude47", // Person Bowing Deeply + "\ud83d\ude4c", // Person Raising Both Hands in Celebration + "\ud83d\ude4f", // Person With Folded Hands + "\ud83d\udde3", // Speaking Head in Silhouette + "\ud83d\udc64", // Bust in Silhouette + "\ud83d\udc65", // Busts in Silhouette + "\ud83d\udeb6", // Pedestrian + "\ud83c\udfc3", // Runner + "\ud83d\udc6f", // Woman With Bunny Ears + "\ud83d\udc83", // Dancer + "\ud83d\udd74", // Man in Business Suit Levitating + "\ud83d\udc6b", // Man and Woman Holding Hands + "\ud83d\udc6c", // Two Men Holding Hands + "\ud83d\udc6d", // Two Women Holding Hands + "\ud83d\udc8f", // Kiss +) diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/data/FakeData.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/data/FakeData.kt new file mode 100644 index 0000000..defc460 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/data/FakeData.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.data + +import xyz.magicalbits.smsremote.R +import xyz.magicalbits.smsremote.conversation.ConversationUiState +import xyz.magicalbits.smsremote.conversation.Message +import xyz.magicalbits.smsremote.device.DeviceScreenState +import xyz.magicalbits.smsremote.profile.ProfileScreenState + +val initialMessages = + listOf( + Message( + "Taylor Brooks", + "You can use all the same stuff", + "8:05 PM", + ), + ) + +val initialMessages2 = + listOf( + Message( + "uahguoidahfg", + "yolo", + "8:05 PM", + ), + ) + +val unreadMessages = initialMessages.filter { it.author != "me" } + +val exampleUiState = + ConversationUiState( + initialMessages = initialMessages, + channelName = "Samsung A14", + channelMembers = 42, + ) + +val exampleUiState2 = + ConversationUiState( + initialMessages = initialMessages2, + channelName = "iPhone XYZ", + channelMembers = 69, + ) + +/** + * Example colleague profile + */ +val colleagueProfile = + ProfileScreenState( + userId = "12345", + photo = R.drawable.someone_else, + name = "Taylor Brooks", + status = "Away", + displayName = "taylor", + position = "Senior Android Dev at Openlane", + twitter = "twitter.com/taylorbrookscodes", + timeZone = "12:25 AM local time (Eastern Daylight Time)", + commonChannels = "2", + ) + +/** + * Example "me" profile. + */ +val meProfile = + ProfileScreenState( + userId = "me", + photo = R.drawable.ali, + name = "Ali Conors", + status = "Online", + displayName = "aliconors", + position = "Senior Android Dev at Yearin\nGoogle Developer Expert", + twitter = "twitter.com/aliconors", + timeZone = "In your timezone", + commonChannels = null, + ) + +val a14Device = + DeviceScreenState( + deviceId = "012345", + name = "Samsung A14", + phoneNumbers = listOf("+420123456789", "+420777444111") + ) + +val iPhoneDevice = + DeviceScreenState( + deviceId = "012345", + name = "iPhone XYZ", + phoneNumbers = listOf("+15558881111") + ) diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/device/Device.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/Device.kt new file mode 100644 index 0000000..a44c506 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/Device.kt @@ -0,0 +1,97 @@ +package xyz.magicalbits.smsremote.device + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import xyz.magicalbits.smsremote.R +import xyz.magicalbits.smsremote.components.baselineHeight + +@Composable +fun DeviceScreen( + deviceData: DeviceScreenState, + nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(), + onPhoneNumberClicked: (String) -> Unit, +) { + val scrollState = rememberScrollState() + + Box( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollInteropConnection), + ) { + Surface { + Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(horizontal = 16.dp)) { + Name(deviceData, Modifier.baselineHeight(32.dp)) + + Spacer(modifier = Modifier.height(8.dp)) + + HorizontalDivider() + + Spacer(modifier = Modifier.height(8.dp)) + + deviceData.phoneNumbers.forEach { + PhoneNumber( + value = it, + modifier = Modifier + .baselineHeight(24.dp) + .clickable(onClick = { onPhoneNumberClicked(it) }), + style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace) + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } +} + +@Composable +private fun Name( + deviceData: DeviceScreenState, + modifier: Modifier +) { + Text( + text = deviceData.name, + modifier = modifier, + style = MaterialTheme.typography.headlineSmall + ) +} + +@Composable +private fun PhoneNumber( + value: String, + modifier: Modifier, + style: TextStyle +) { + Text( + text = value, + modifier = modifier, + style = style + ) +} + +@Composable +fun DeviceError() { + Text(stringResource(R.string.device_error)) +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceFragment.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceFragment.kt new file mode 100644 index 0000000..d1a6c5c --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceFragment.kt @@ -0,0 +1,87 @@ +package xyz.magicalbits.smsremote.device + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.NavController +import xyz.magicalbits.smsremote.MainViewModel +import xyz.magicalbits.smsremote.R +import xyz.magicalbits.smsremote.components.JetchatAppBar +import xyz.magicalbits.smsremote.theme.JetchatTheme +import kotlin.getValue +import androidx.navigation.findNavController +import kotlinx.coroutines.launch + +class DeviceFragment : Fragment() { + private val viewModel: DeviceViewModel by viewModels() + private val activityViewModel: MainViewModel by activityViewModels() + + override fun onAttach(context: Context) { + super.onAttach(context) + // Consider using safe args plugin + val deviceName = arguments?.getString("deviceName") + viewModel.setDeviceId(deviceName) + } + + @OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false) + + rootView.findViewById(R.id.toolbar_compose_view).apply { + setContent { + JetchatTheme { + JetchatAppBar( + // Reset the minimum bounds that are passed to the root of a compose tree + modifier = Modifier.wrapContentSize(), + onNavIconPressed = { activityViewModel.openDrawer() }, + title = { }, + ) + } + } + } + + rootView.findViewById(R.id.profile_compose_view).apply { + setContent { + val deviceData by viewModel.deviceData.observeAsState() + val nestedScrollInteropConnection = rememberNestedScrollInteropConnection() + + JetchatTheme { + if (deviceData == null) { + DeviceError() + } else { + val navController: NavController = rootView.findNavController() + DeviceScreen( + deviceData = deviceData!!, + nestedScrollInteropConnection = nestedScrollInteropConnection, + onPhoneNumberClicked = { + val args = Bundle(1) + args.putString("phoneNumber", it) + navController.navigate(R.id.action_device_to_conversation, args) + }, + ) + } + } + } + } + + return rootView + } +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceViewModel.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceViewModel.kt new file mode 100644 index 0000000..179cada --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/device/DeviceViewModel.kt @@ -0,0 +1,35 @@ +package xyz.magicalbits.smsremote.device + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import xyz.magicalbits.smsremote.data.a14Device +import xyz.magicalbits.smsremote.data.iPhoneDevice + +class DeviceViewModel : ViewModel() { + private var deviceId: String = "" + private val _deviceData = MutableLiveData() + val deviceData: LiveData = _deviceData + + fun setDeviceId(newDeviceId: String?) { + if (newDeviceId != null) { + deviceId = newDeviceId + } + + // placeholder since there's no API reading logic yet + _deviceData.value = + if (deviceId == "Samsung A14") { + a14Device + } else { + iPhoneDevice + } + } +} + +@Immutable +data class DeviceScreenState( + val deviceId: String, + val name: String, + val phoneNumbers: List +) diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/Previews.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/Previews.kt new file mode 100644 index 0000000..c1201f8 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/Previews.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.profile + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import xyz.magicalbits.smsremote.data.colleagueProfile +import xyz.magicalbits.smsremote.data.meProfile +import xyz.magicalbits.smsremote.theme.JetchatTheme + +@Preview(widthDp = 340, name = "340 width - Me") +@Composable +fun ProfilePreview340() { + JetchatTheme { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 480, name = "480 width - Me") +@Composable +fun ProfilePreview480Me() { + JetchatTheme { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 480, name = "480 width - Other") +@Composable +fun ProfilePreview480Other() { + JetchatTheme { + ProfileScreen(colleagueProfile) + } +} +@Preview(widthDp = 340, name = "340 width - Me - Dark") +@Composable +fun ProfilePreview340MeDark() { + JetchatTheme(isDarkTheme = true) { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 480, name = "480 width - Me - Dark") +@Composable +fun ProfilePreview480MeDark() { + JetchatTheme(isDarkTheme = true) { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 480, name = "480 width - Other - Dark") +@Composable +fun ProfilePreview480OtherDark() { + JetchatTheme(isDarkTheme = true) { + ProfileScreen(colleagueProfile) + } +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/Profile.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/Profile.kt new file mode 100644 index 0000000..1ac9d3a --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/Profile.kt @@ -0,0 +1,294 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.profile + +import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup +import xyz.magicalbits.smsremote.R +import xyz.magicalbits.smsremote.components.AnimatingFabContent +import xyz.magicalbits.smsremote.components.baselineHeight +import xyz.magicalbits.smsremote.data.colleagueProfile +import xyz.magicalbits.smsremote.data.meProfile +import xyz.magicalbits.smsremote.theme.JetchatTheme + +//@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun ProfileScreen( + userData: ProfileScreenState, + nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(), +) { + var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } + if (functionalityNotAvailablePopupShown) { + FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } + } + + val scrollState = rememberScrollState() + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollInteropConnection) + .systemBarsPadding(), + ) { + Surface { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState), + ) { + ProfileHeader( + scrollState, + userData, + this@BoxWithConstraints.maxHeight, + ) + UserInfoFields(userData, this@BoxWithConstraints.maxHeight) + } + } + + val fabExtended by remember { derivedStateOf { scrollState.value == 0 } } + ProfileFab( + extended = fabExtended, + userIsMe = userData.isMe(), + modifier = Modifier + .align(Alignment.BottomEnd) + // Offsets the FAB to compensate for CoordinatorLayout collapsing behaviour + .offset(y = ((-100).dp)), + onFabClicked = { functionalityNotAvailablePopupShown = true }, + ) + } +} + +@Composable +private fun UserInfoFields(userData: ProfileScreenState, containerHeight: Dp) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + + NameAndPosition(userData) + + ProfileProperty(stringResource(R.string.display_name), userData.displayName) + + ProfileProperty(stringResource(R.string.status), userData.status) + + ProfileProperty(stringResource(R.string.twitter), userData.twitter, isLink = true) + + userData.timeZone?.let { + ProfileProperty(stringResource(R.string.timezone), userData.timeZone) + } + + // Add a spacer that always shows part (320.dp) of the fields list regardless of the device, + // in order to always leave some content at the top. + Spacer(Modifier.height((containerHeight - 320.dp).coerceAtLeast(0.dp))) + } +} + +@Composable +private fun NameAndPosition(userData: ProfileScreenState) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Name( + userData, + modifier = Modifier.baselineHeight(32.dp), + ) + Position( + userData, + modifier = Modifier + .padding(bottom = 20.dp) + .baselineHeight(24.dp), + ) + } +} + +@Composable +private fun Name(userData: ProfileScreenState, modifier: Modifier = Modifier) { + Text( + text = userData.name, + modifier = modifier, + style = MaterialTheme.typography.headlineSmall, + ) +} + +@Composable +private fun Position(userData: ProfileScreenState, modifier: Modifier = Modifier) { + Text( + text = userData.position, + modifier = modifier, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun ProfileHeader(scrollState: ScrollState, data: ProfileScreenState, containerHeight: Dp) { + val offset = (scrollState.value / 2) + val offsetDp = with(LocalDensity.current) { offset.toDp() } + + data.photo?.let { + Image( + modifier = Modifier + .heightIn(max = containerHeight / 2) + .fillMaxWidth() + // TODO: Update to use offset to avoid recomposition + .padding( + start = 16.dp, + top = offsetDp, + end = 16.dp, + ) + .clip(CircleShape), + painter = painterResource(id = it), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } +} + +@Composable +fun ProfileProperty(label: String, value: String, isLink: Boolean = false) { + Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) { + HorizontalDivider() + Text( + text = label, + modifier = Modifier.baselineHeight(24.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val style = if (isLink) { + MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary) + } else { + MaterialTheme.typography.bodyLarge + } + Text( + text = value, + modifier = Modifier.baselineHeight(24.dp), + style = style, + ) + } +} + +@Composable +fun ProfileError() { + Text(stringResource(R.string.profile_error)) +} + +@Composable +fun ProfileFab(extended: Boolean, userIsMe: Boolean, modifier: Modifier = Modifier, onFabClicked: () -> Unit = { }) { + key(userIsMe) { + // Prevent multiple invocations to execute during composition + FloatingActionButton( + onClick = onFabClicked, + modifier = modifier + .padding(16.dp) + .navigationBarsPadding() + .height(48.dp) + .widthIn(min = 48.dp), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + ) { + AnimatingFabContent( + icon = { + Icon( + painter = painterResource(id = if (userIsMe) R.drawable.ic_create else R.drawable.ic_chat), + contentDescription = stringResource( + if (userIsMe) R.string.edit_profile else R.string.message, + ), + ) + }, + text = { + Text( + text = stringResource( + id = if (userIsMe) R.string.edit_profile else R.string.message, + ), + ) + }, + extended = extended, + ) + } + } +} + +@Preview(widthDp = 640, heightDp = 360) +@Composable +fun ConvPreviewLandscapeMeDefault() { + JetchatTheme { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 360, heightDp = 480) +@Composable +fun ConvPreviewPortraitMeDefault() { + JetchatTheme { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 360, heightDp = 480) +@Composable +fun ConvPreviewPortraitOtherDefault() { + JetchatTheme { + ProfileScreen(colleagueProfile) + } +} + +@Preview +@Composable +fun ProfileFabPreview() { + JetchatTheme { + ProfileFab(extended = true, userIsMe = false) + } +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/ProfileFragment.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/ProfileFragment.kt new file mode 100644 index 0000000..dcd0aa0 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/ProfileFragment.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.profile + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup +import xyz.magicalbits.smsremote.MainViewModel +import xyz.magicalbits.smsremote.R +import xyz.magicalbits.smsremote.components.JetchatAppBar +import xyz.magicalbits.smsremote.theme.JetchatTheme + +class ProfileFragment : Fragment() { + private val viewModel: ProfileViewModel by viewModels() + private val activityViewModel: MainViewModel by activityViewModels() + + override fun onAttach(context: Context) { + super.onAttach(context) + // Consider using safe args plugin + val userId = arguments?.getString("userId") + viewModel.setUserId(userId) + } + + @OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false) + + rootView.findViewById(R.id.toolbar_compose_view).apply { + setContent { + var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } + if (functionalityNotAvailablePopupShown) { + FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } + } + + JetchatTheme { + JetchatAppBar( + // Reset the minimum bounds that are passed to the root of a compose tree + modifier = Modifier.wrapContentSize(), + onNavIconPressed = { activityViewModel.openDrawer() }, + title = { }, + actions = { + // More icon + Icon( + painter = painterResource(id = R.drawable.ic_more_vert), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .clickable(onClick = { + functionalityNotAvailablePopupShown = true + }) + .padding(horizontal = 12.dp, vertical = 16.dp) + .height(24.dp), + contentDescription = stringResource(id = R.string.more_options), + ) + }, + ) + } + } + } + + rootView.findViewById(R.id.profile_compose_view).apply { + setContent { + val userData by viewModel.userData.observeAsState() + val nestedScrollInteropConnection = rememberNestedScrollInteropConnection() + + JetchatTheme { + if (userData == null) { + ProfileError() + } else { + ProfileScreen( + userData = userData!!, + nestedScrollInteropConnection = nestedScrollInteropConnection, + ) + } + } + } + } + return rootView + } +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/ProfileViewModel.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/ProfileViewModel.kt new file mode 100644 index 0000000..27c8864 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/profile/ProfileViewModel.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.profile + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import xyz.magicalbits.smsremote.data.colleagueProfile +import xyz.magicalbits.smsremote.data.meProfile + +class ProfileViewModel : ViewModel() { + private var userId: String = "" + + fun setUserId(newUserId: String?) { + if (newUserId != userId) { + userId = newUserId ?: meProfile.userId + } + // Workaround for simplicity + _userData.value = + if (userId == meProfile.userId || userId == meProfile.displayName) { + meProfile + } else { + colleagueProfile + } + } + + private val _userData = MutableLiveData() + val userData: LiveData = _userData +} + +@Immutable +data class ProfileScreenState( + val userId: String, + @param:DrawableRes val photo: Int?, + val name: String, + val status: String, + val displayName: String, + val position: String, + val twitter: String = "", + val timeZone: String?, // Null if me + val commonChannels: String?, // Null if me +) { + fun isMe() = userId == meProfile.userId +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Color.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Color.kt new file mode 100644 index 0000000..0c7f33a --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Color.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.theme + +import androidx.compose.ui.graphics.Color + +val Blue10 = Color(0xFF000F5E) +val Blue20 = Color(0xFF001E92) +val Blue30 = Color(0xFF002ECC) +val Blue40 = Color(0xFF1546F6) +val Blue80 = Color(0xFFB8C3FF) +val Blue90 = Color(0xFFDDE1FF) + +val DarkBlue10 = Color(0xFF00036B) +val DarkBlue20 = Color(0xFF000BA6) +val DarkBlue30 = Color(0xFF1026D3) +val DarkBlue40 = Color(0xFF3648EA) +val DarkBlue80 = Color(0xFFBBC2FF) +val DarkBlue90 = Color(0xFFDEE0FF) + +val Yellow10 = Color(0xFF261900) +val Yellow20 = Color(0xFF402D00) +val Yellow30 = Color(0xFF5C4200) +val Yellow40 = Color(0xFF7A5900) +val Yellow80 = Color(0xFFFABD1B) +val Yellow90 = Color(0xFFFFDE9C) + +val Red10 = Color(0xFF410001) +val Red20 = Color(0xFF680003) +val Red30 = Color(0xFF930006) +val Red40 = Color(0xFFBA1B1B) +val Red80 = Color(0xFFFFB4A9) +val Red90 = Color(0xFFFFDAD4) + +val Grey10 = Color(0xFF191C1D) +val Grey20 = Color(0xFF2D3132) +val Grey80 = Color(0xFFC4C7C7) +val Grey90 = Color(0xFFE0E3E3) +val Grey95 = Color(0xFFEFF1F1) +val Grey99 = Color(0xFFFBFDFD) + +val BlueGrey30 = Color(0xFF45464F) +val BlueGrey50 = Color(0xFF767680) +val BlueGrey60 = Color(0xFF90909A) +val BlueGrey80 = Color(0xFFC6C5D0) +val BlueGrey90 = Color(0xFFE2E1EC) diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Themes.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Themes.kt new file mode 100644 index 0000000..afc47e5 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Themes.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.theme + +import android.annotation.SuppressLint +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +val JetchatDarkColorScheme = darkColorScheme( + primary = Blue80, + onPrimary = Blue20, + primaryContainer = Blue30, + onPrimaryContainer = Blue90, + inversePrimary = Blue40, + secondary = DarkBlue80, + onSecondary = DarkBlue20, + secondaryContainer = DarkBlue30, + onSecondaryContainer = DarkBlue90, + tertiary = Yellow80, + onTertiary = Yellow20, + tertiaryContainer = Yellow30, + onTertiaryContainer = Yellow90, + error = Red80, + onError = Red20, + errorContainer = Red30, + onErrorContainer = Red90, + background = Grey10, + onBackground = Grey90, + surface = Grey10, + onSurface = Grey80, + inverseSurface = Grey90, + inverseOnSurface = Grey20, + surfaceVariant = BlueGrey30, + onSurfaceVariant = BlueGrey80, + outline = BlueGrey60, +) + +val JetchatLightColorScheme = lightColorScheme( + primary = Blue40, + onPrimary = Color.White, + primaryContainer = Blue90, + onPrimaryContainer = Blue10, + inversePrimary = Blue80, + secondary = DarkBlue40, + onSecondary = Color.White, + secondaryContainer = DarkBlue90, + onSecondaryContainer = DarkBlue10, + tertiary = Yellow40, + onTertiary = Color.White, + tertiaryContainer = Yellow90, + onTertiaryContainer = Yellow10, + error = Red40, + onError = Color.White, + errorContainer = Red90, + onErrorContainer = Red10, + background = Grey99, + onBackground = Grey10, + surface = Grey99, + onSurface = Grey10, + inverseSurface = Grey20, + inverseOnSurface = Grey95, + surfaceVariant = BlueGrey90, + onSurfaceVariant = BlueGrey30, + outline = BlueGrey50, +) + +@SuppressLint("NewApi") +@Composable +fun JetchatTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), isDynamicColor: Boolean = true, content: @Composable () -> Unit) { + val dynamicColor = isDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val myColorScheme = when { + dynamicColor && isDarkTheme -> { + dynamicDarkColorScheme(LocalContext.current) + } + + dynamicColor && !isDarkTheme -> { + dynamicLightColorScheme(LocalContext.current) + } + + isDarkTheme -> JetchatDarkColorScheme + + else -> JetchatLightColorScheme + } + + MaterialTheme( + colorScheme = myColorScheme, + typography = JetchatTypography, + content = content, + ) +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Typography.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Typography.kt new file mode 100644 index 0000000..201a1c7 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/theme/Typography.kt @@ -0,0 +1,182 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont +import androidx.compose.ui.unit.sp +import xyz.magicalbits.smsremote.R + +val provider = + GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs, + ) + +val MontserratFont = GoogleFont(name = "Montserrat") + +val KarlaFont = GoogleFont(name = "Karla") + +val MontserratFontFamily = + FontFamily( + Font(googleFont = MontserratFont, fontProvider = provider), + Font(resId = R.font.montserrat_regular), + Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.Light), + Font(resId = R.font.montserrat_light, weight = FontWeight.Light), + Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.Medium), + Font(resId = R.font.montserrat_medium, weight = FontWeight.Medium), + Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.SemiBold), + Font(resId = R.font.montserrat_semibold, weight = FontWeight.SemiBold), + ) + +val KarlaFontFamily = + FontFamily( + Font(googleFont = KarlaFont, fontProvider = provider), + Font(resId = R.font.karla_regular), + Font(googleFont = KarlaFont, fontProvider = provider, weight = FontWeight.Bold), + Font(resId = R.font.karla_bold, weight = FontWeight.Bold), + ) + +val JetchatTypography = + Typography( + displayLarge = + TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.Light, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = 0.sp, + ), + displayMedium = + TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.Light, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = + TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + headlineLarge = + TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = + TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = + TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = + TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = + TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = + TextStyle( + fontFamily = KarlaFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = + TextStyle( + fontFamily = KarlaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + bodyMedium = + TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = + TextStyle( + fontFamily = KarlaFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = + TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = + TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = + TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + ) diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/JetChatWidget.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/JetChatWidget.kt new file mode 100644 index 0000000..081fe63 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/JetChatWidget.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.widget + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.provideContent +import xyz.magicalbits.smsremote.data.unreadMessages +import xyz.magicalbits.smsremote.widget.composables.MessagesWidget + +class JetChatWidget : GlanceAppWidget() { + override suspend fun provideGlance( + context: Context, + id: GlanceId, + ) { + provideContent { + GlanceTheme { + MessagesWidget(unreadMessages.toList()) + } + } + } +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/WidgetReceiver.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/WidgetReceiver.kt new file mode 100644 index 0000000..8f92241 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/WidgetReceiver.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.widget + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +class WidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget + get() = JetChatWidget() +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/composables/MessagesWidget.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/composables/MessagesWidget.kt new file mode 100644 index 0000000..2b6a80d --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/composables/MessagesWidget.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.widget.composables + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.actionStartActivity +import androidx.glance.action.clickable +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.layout.Column +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.text.Text +import xyz.magicalbits.smsremote.NavActivity +import xyz.magicalbits.smsremote.R +import xyz.magicalbits.smsremote.conversation.Message +import xyz.magicalbits.smsremote.widget.theme.JetChatGlanceTextStyles +import xyz.magicalbits.smsremote.widget.theme.JetchatGlanceColorScheme + +@Composable +fun MessagesWidget(messages: List) { + Scaffold(titleBar = { + TitleBar( + startIcon = ImageProvider(R.drawable.ic_jetchat), + iconColor = null, + title = LocalContext.current.getString(R.string.messages_widget_title), + ) + }, backgroundColor = JetchatGlanceColorScheme.colors.background) { + LazyColumn(modifier = GlanceModifier.fillMaxWidth()) { + messages.forEach { + item { + Column(modifier = GlanceModifier.fillMaxWidth()) { + MessageItem(it) + Spacer(modifier = GlanceModifier.height(10.dp)) + } + } + } + } + } +} + +@Composable +fun MessageItem(message: Message) { + Column(modifier = GlanceModifier.clickable(actionStartActivity()).fillMaxWidth()) { + Text( + text = message.author, + style = JetChatGlanceTextStyles.titleMedium, + ) + Text( + text = message.content, + style = JetChatGlanceTextStyles.bodyMedium, + ) + } +} + +@Preview +@Composable +fun MessageItemPreview() { + MessageItem(Message("John", "This is a preview of the message Item", "8:02PM")) +} + +@Preview +@Composable +fun WidgetPreview() { + MessagesWidget(listOf(Message("John", "This is a preview of the message Item", "8:02PM"))) +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/theme/Theme.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/theme/Theme.kt new file mode 100644 index 0000000..bdf2841 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/theme/Theme.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.widget.theme + +import androidx.glance.material3.ColorProviders +import xyz.magicalbits.smsremote.theme.JetchatDarkColorScheme +import xyz.magicalbits.smsremote.theme.JetchatLightColorScheme + +object JetchatGlanceColorScheme { + val colors = ColorProviders( + light = JetchatLightColorScheme, + dark = JetchatDarkColorScheme, + ) +} diff --git a/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/theme/Type.kt b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/theme/Type.kt new file mode 100644 index 0000000..cd2d124 --- /dev/null +++ b/android/src/main/kotlin/xyz/magicalbits/smsremote/widget/theme/Type.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 MagicalBits + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.magicalbits.smsremote.widget.theme + +import androidx.compose.ui.unit.sp +import androidx.glance.text.FontWeight +import androidx.glance.text.TextStyle + +object JetChatGlanceTextStyles { + + val titleMedium = TextStyle( + fontSize = 16.sp, + color = JetchatGlanceColorScheme.colors.onSurfaceVariant, + fontWeight = FontWeight.Bold, + ) + val bodyMedium = TextStyle( + fontSize = 16.sp, + color = JetchatGlanceColorScheme.colors.onSurfaceVariant, + fontWeight = FontWeight.Normal, + ) +} diff --git a/android/src/main/res/drawable-nodpi/ali.png b/android/src/main/res/drawable-nodpi/ali.png new file mode 100644 index 0000000..eab3c65 Binary files /dev/null and b/android/src/main/res/drawable-nodpi/ali.png differ diff --git a/android/src/main/res/drawable-nodpi/someone_else.jpg b/android/src/main/res/drawable-nodpi/someone_else.jpg new file mode 100644 index 0000000..95bc4a8 Binary files /dev/null and b/android/src/main/res/drawable-nodpi/someone_else.jpg differ diff --git a/android/src/main/res/drawable-nodpi/sticker.png b/android/src/main/res/drawable-nodpi/sticker.png new file mode 100644 index 0000000..54bbcfb Binary files /dev/null and b/android/src/main/res/drawable-nodpi/sticker.png differ diff --git a/android/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..6598a88 --- /dev/null +++ b/android/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_alternate_email.xml b/android/src/main/res/drawable/ic_alternate_email.xml new file mode 100644 index 0000000..37d1268 --- /dev/null +++ b/android/src/main/res/drawable/ic_alternate_email.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_arrow_downward.xml b/android/src/main/res/drawable/ic_arrow_downward.xml new file mode 100644 index 0000000..66f1ae3 --- /dev/null +++ b/android/src/main/res/drawable/ic_arrow_downward.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_person_24.xml b/android/src/main/res/drawable/ic_baseline_person_24.xml new file mode 100644 index 0000000..53899e9 --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_person_24.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/android/src/main/res/drawable/ic_chat.xml b/android/src/main/res/drawable/ic_chat.xml new file mode 100644 index 0000000..e53bb33 --- /dev/null +++ b/android/src/main/res/drawable/ic_chat.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_create.xml b/android/src/main/res/drawable/ic_create.xml new file mode 100644 index 0000000..db9b31f --- /dev/null +++ b/android/src/main/res/drawable/ic_create.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_duo.xml b/android/src/main/res/drawable/ic_duo.xml new file mode 100644 index 0000000..7e0cdcd --- /dev/null +++ b/android/src/main/res/drawable/ic_duo.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_info.xml b/android/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000..1ece034 --- /dev/null +++ b/android/src/main/res/drawable/ic_info.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_insert_photo.xml b/android/src/main/res/drawable/ic_insert_photo.xml new file mode 100644 index 0000000..083151a --- /dev/null +++ b/android/src/main/res/drawable/ic_insert_photo.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_jetchat.xml b/android/src/main/res/drawable/ic_jetchat.xml new file mode 100644 index 0000000..1f33c7f --- /dev/null +++ b/android/src/main/res/drawable/ic_jetchat.xml @@ -0,0 +1,42 @@ + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_jetchat_back.xml b/android/src/main/res/drawable/ic_jetchat_back.xml new file mode 100644 index 0000000..b238144 --- /dev/null +++ b/android/src/main/res/drawable/ic_jetchat_back.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/android/src/main/res/drawable/ic_jetchat_front.xml b/android/src/main/res/drawable/ic_jetchat_front.xml new file mode 100644 index 0000000..5946519 --- /dev/null +++ b/android/src/main/res/drawable/ic_jetchat_front.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/android/src/main/res/drawable/ic_launcher_foreground.xml b/android/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..a370dbd --- /dev/null +++ b/android/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_launcher_monochrome.xml b/android/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..9a28510 --- /dev/null +++ b/android/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_mic.xml b/android/src/main/res/drawable/ic_mic.xml new file mode 100644 index 0000000..ed523bb --- /dev/null +++ b/android/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/ic_mood.xml b/android/src/main/res/drawable/ic_mood.xml new file mode 100644 index 0000000..b5f4002 --- /dev/null +++ b/android/src/main/res/drawable/ic_mood.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/ic_more_vert.xml b/android/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000..59400ec --- /dev/null +++ b/android/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_place.xml b/android/src/main/res/drawable/ic_place.xml new file mode 100644 index 0000000..ddb5a94 --- /dev/null +++ b/android/src/main/res/drawable/ic_place.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_search.xml b/android/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..20c7b4e --- /dev/null +++ b/android/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/jetchat_logo.xml b/android/src/main/res/drawable/jetchat_logo.xml new file mode 100644 index 0000000..20e7035 --- /dev/null +++ b/android/src/main/res/drawable/jetchat_logo.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/android/src/main/res/drawable/widget_icon.png b/android/src/main/res/drawable/widget_icon.png new file mode 100644 index 0000000..70d386e Binary files /dev/null and b/android/src/main/res/drawable/widget_icon.png differ diff --git a/android/src/main/res/font/karla_bold.ttf b/android/src/main/res/font/karla_bold.ttf new file mode 100644 index 0000000..052231c Binary files /dev/null and b/android/src/main/res/font/karla_bold.ttf differ diff --git a/android/src/main/res/font/karla_regular.ttf b/android/src/main/res/font/karla_regular.ttf new file mode 100644 index 0000000..4269aa0 Binary files /dev/null and b/android/src/main/res/font/karla_regular.ttf differ diff --git a/android/src/main/res/font/montserrat_light.ttf b/android/src/main/res/font/montserrat_light.ttf new file mode 100644 index 0000000..990857d Binary files /dev/null and b/android/src/main/res/font/montserrat_light.ttf differ diff --git a/android/src/main/res/font/montserrat_medium.ttf b/android/src/main/res/font/montserrat_medium.ttf new file mode 100755 index 0000000..6e079f6 Binary files /dev/null and b/android/src/main/res/font/montserrat_medium.ttf differ diff --git a/android/src/main/res/font/montserrat_regular.ttf b/android/src/main/res/font/montserrat_regular.ttf new file mode 100755 index 0000000..8d443d5 Binary files /dev/null and b/android/src/main/res/font/montserrat_regular.ttf differ diff --git a/android/src/main/res/font/montserrat_semibold.ttf b/android/src/main/res/font/montserrat_semibold.ttf new file mode 100755 index 0000000..f8a43f2 Binary files /dev/null and b/android/src/main/res/font/montserrat_semibold.ttf differ diff --git a/android/src/main/res/layout/content_main.xml b/android/src/main/res/layout/content_main.xml new file mode 100644 index 0000000..f185959 --- /dev/null +++ b/android/src/main/res/layout/content_main.xml @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/android/src/main/res/layout/fragment_profile.xml b/android/src/main/res/layout/fragment_profile.xml new file mode 100644 index 0000000..3cdf5ea --- /dev/null +++ b/android/src/main/res/layout/fragment_profile.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/menu/activity_main_drawer.xml b/android/src/main/res/menu/activity_main_drawer.xml new file mode 100644 index 0000000..aaae5af --- /dev/null +++ b/android/src/main/res/menu/activity_main_drawer.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..6526af6 Binary files /dev/null and b/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/src/main/res/navigation/mobile_navigation.xml b/android/src/main/res/navigation/mobile_navigation.xml new file mode 100644 index 0000000..84b650c --- /dev/null +++ b/android/src/main/res/navigation/mobile_navigation.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/res/values-night/colors.xml b/android/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..afcea2b --- /dev/null +++ b/android/src/main/res/values-night/colors.xml @@ -0,0 +1,22 @@ + + + + + + @color/yellow400 + @color/blue300 + diff --git a/android/src/main/res/values-night/themes.xml b/android/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..be7da11 --- /dev/null +++ b/android/src/main/res/values-night/themes.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/android/src/main/res/values-v23/font_certs.xml b/android/src/main/res/values-v23/font_certs.xml new file mode 100644 index 0000000..6dec447 --- /dev/null +++ b/android/src/main/res/values-v23/font_certs.xml @@ -0,0 +1,32 @@ + + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK + + + diff --git a/android/src/main/res/values-v23/themes.xml b/android/src/main/res/values-v23/themes.xml new file mode 100644 index 0000000..4af8a86 --- /dev/null +++ b/android/src/main/res/values-v23/themes.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/android/src/main/res/values-v27/themes.xml b/android/src/main/res/values-v27/themes.xml new file mode 100644 index 0000000..4fcb700 --- /dev/null +++ b/android/src/main/res/values-v27/themes.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/android/src/main/res/values/colors.xml b/android/src/main/res/values/colors.xml new file mode 100644 index 0000000..d4a326c --- /dev/null +++ b/android/src/main/res/values/colors.xml @@ -0,0 +1,35 @@ + + + + + + #0540F2 + #001CCF + #F3B711 + + #6F7EF9 + #4860F7 + #F6E547 + + + @color/yellow700 + @color/blue500 + + + #4D000000 + + diff --git a/android/src/main/res/values/dimens.xml b/android/src/main/res/values/dimens.xml new file mode 100644 index 0000000..a15764b --- /dev/null +++ b/android/src/main/res/values/dimens.xml @@ -0,0 +1,25 @@ + + + + + 16dp + 16dp + 16dp + 8dp + 24dp + 16dp + diff --git a/android/src/main/res/values/ic_launcher_background.xml b/android/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..83e59df --- /dev/null +++ b/android/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,20 @@ + + + + + #0540F2 + \ No newline at end of file diff --git a/android/src/main/res/values/ids.xml b/android/src/main/res/values/ids.xml new file mode 100644 index 0000000..3f5fd04 --- /dev/null +++ b/android/src/main/res/values/ids.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..d4a0159 --- /dev/null +++ b/android/src/main/res/values/strings.xml @@ -0,0 +1,76 @@ + + + + SMS Remote + + Open navigation drawer + Close navigation drawer + Composer + android.studio@android.com + Navigation header + Settings + + Home + Gallery + Slideshow + Conversations + Profile + Jump to bottom + Send + me + 8:30 PM + %d members + Message #composers + ◀ Swipe to cancel + Emojis + Stickers + + Message + Edit Profile + There was an error loading the profile + Bio + Display name + Status + Timezone + Twitter + Channels in common + Lorem or Ipsum + + + There was an error loading the device + + + + Emoji selector + Show Emoji selector + Direct Message + Attach Photo + Location selector + Start videochat + Text input + Functionality currently not available + Grab a beverage and check back later! + Attached image + Search + Information + More options + Touch and hold to record + Record voice message + JetChat unread messages + Add Widget to Home Page + + diff --git a/android/src/main/res/values/themes.xml b/android/src/main/res/values/themes.xml new file mode 100644 index 0000000..255aaaf --- /dev/null +++ b/android/src/main/res/values/themes.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + +