wip
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Used to communicate between screens.
|
||||
*/
|
||||
class MainViewModel : ViewModel() {
|
||||
private val _drawerShouldBeOpened = MutableStateFlow(false)
|
||||
val drawerShouldBeOpened = _drawerShouldBeOpened.asStateFlow()
|
||||
|
||||
fun openDrawer() {
|
||||
_drawerShouldBeOpened.value = true
|
||||
}
|
||||
|
||||
fun resetOpenDrawerAction() {
|
||||
_drawerShouldBeOpened.value = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
text = {
|
||||
Text(
|
||||
text = "Functionality not available \uD83D\uDE48",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = "CLOSE")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.components
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.util.lerp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A layout that shows an icon and a text element used as the content for a FAB that extends with
|
||||
* an animation.
|
||||
*/
|
||||
@Composable
|
||||
fun AnimatingFabContent(
|
||||
icon: @Composable () -> Unit,
|
||||
text: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
extended: Boolean = true,
|
||||
) {
|
||||
val currentState = if (extended) ExpandableFabStates.Extended else ExpandableFabStates.Collapsed
|
||||
val transition = updateTransition(currentState, "fab_transition")
|
||||
|
||||
val textOpacity by transition.animateFloat(
|
||||
transitionSpec = {
|
||||
if (targetState == ExpandableFabStates.Collapsed) {
|
||||
tween(
|
||||
easing = LinearEasing,
|
||||
durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames
|
||||
)
|
||||
} else {
|
||||
tween(
|
||||
easing = LinearEasing,
|
||||
delayMillis = (transitionDuration / 3f).roundToInt(), // 4 / 12 frames
|
||||
durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "fab_text_opacity",
|
||||
) { state ->
|
||||
if (state == ExpandableFabStates.Collapsed) {
|
||||
0f
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
}
|
||||
val fabWidthFactor by transition.animateFloat(
|
||||
transitionSpec = {
|
||||
if (targetState == ExpandableFabStates.Collapsed) {
|
||||
tween(
|
||||
easing = FastOutSlowInEasing,
|
||||
durationMillis = transitionDuration,
|
||||
)
|
||||
} else {
|
||||
tween(
|
||||
easing = FastOutSlowInEasing,
|
||||
durationMillis = transitionDuration,
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "fab_width_factor",
|
||||
) { state ->
|
||||
if (state == ExpandableFabStates.Collapsed) {
|
||||
0f
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
}
|
||||
// Deferring reads using lambdas instead of Floats here can improve performance,
|
||||
// preventing recompositions.
|
||||
IconAndTextRow(
|
||||
icon,
|
||||
text,
|
||||
{ textOpacity },
|
||||
{ fabWidthFactor },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IconAndTextRow(
|
||||
icon: @Composable () -> Unit,
|
||||
text: @Composable () -> Unit,
|
||||
opacityProgress: () -> Float, // Lambdas instead of Floats, to defer read
|
||||
widthProgress: () -> Float,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Layout(
|
||||
modifier = modifier,
|
||||
content = {
|
||||
icon()
|
||||
Box(modifier = Modifier.graphicsLayer { alpha = opacityProgress() }) {
|
||||
text()
|
||||
}
|
||||
},
|
||||
) { measurables, constraints ->
|
||||
|
||||
val iconPlaceable = measurables[0].measure(constraints)
|
||||
val textPlaceable = measurables[1].measure(constraints)
|
||||
|
||||
val height = constraints.maxHeight
|
||||
|
||||
// FAB has an aspect ratio of 1 so the initial width is the height
|
||||
val initialWidth = height.toFloat()
|
||||
|
||||
// Use it to get the padding
|
||||
val iconPadding = (initialWidth - iconPlaceable.width) / 2f
|
||||
|
||||
// The full width will be : padding + icon + padding + text + padding
|
||||
val expandedWidth = iconPlaceable.width + textPlaceable.width + iconPadding * 3
|
||||
|
||||
// Apply the animation factor to go from initialWidth to fullWidth
|
||||
val width = lerp(initialWidth, expandedWidth, widthProgress())
|
||||
|
||||
layout(width.roundToInt(), height) {
|
||||
iconPlaceable.place(
|
||||
iconPadding.roundToInt(),
|
||||
constraints.maxHeight / 2 - iconPlaceable.height / 2,
|
||||
)
|
||||
textPlaceable.place(
|
||||
(iconPlaceable.width + iconPadding * 2).roundToInt(),
|
||||
constraints.maxHeight / 2 - textPlaceable.height / 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class ExpandableFabStates { Collapsed, Extended }
|
||||
|
||||
private const val transitionDuration = 200
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.components
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.FirstBaseline
|
||||
import androidx.compose.ui.layout.LastBaseline
|
||||
import androidx.compose.ui.layout.LayoutModifier
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/**
|
||||
* Applied to a Text, it sets the distance between the top and the first baseline. It
|
||||
* also makes the bottom of the element coincide with the last baseline of the text.
|
||||
*
|
||||
* _______________
|
||||
* | | ↑
|
||||
* | | | heightFromBaseline
|
||||
* |Hello, World!| ↓
|
||||
* ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
|
||||
*
|
||||
* This modifier can be used to distribute multiple text elements using a certain distance between
|
||||
* baselines.
|
||||
*/
|
||||
data class BaselineHeightModifier(
|
||||
val heightFromBaseline: Dp,
|
||||
) : LayoutModifier {
|
||||
override fun MeasureScope.measure(
|
||||
measurable: Measurable,
|
||||
constraints: Constraints,
|
||||
): MeasureResult {
|
||||
val textPlaceable = measurable.measure(constraints)
|
||||
val firstBaseline = textPlaceable[FirstBaseline]
|
||||
val lastBaseline = textPlaceable[LastBaseline]
|
||||
|
||||
val height = heightFromBaseline.roundToPx() + lastBaseline - firstBaseline
|
||||
return layout(constraints.maxWidth, height) {
|
||||
val topY = heightFromBaseline.roundToPx() - firstBaseline
|
||||
textPlaceable.place(0, topY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.baselineHeight(heightFromBaseline: Dp): Modifier = this.then(BaselineHeightModifier(heightFromBaseline))
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package xyz.magicalbits.smsremote.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import xyz.magicalbits.smsremote.R
|
||||
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun JetchatAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
onNavIconPressed: () -> Unit = { },
|
||||
title: @Composable () -> Unit,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
modifier = modifier,
|
||||
actions = actions,
|
||||
title = title,
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = {
|
||||
JetchatIcon(
|
||||
contentDescription = stringResource(id = R.string.navigation_drawer_open),
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clickable(onClick = onNavIconPressed)
|
||||
.padding(16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun JetchatAppBarPreview() {
|
||||
JetchatTheme {
|
||||
JetchatAppBar(title = { Text("Preview!") })
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun JetchatAppBarPreviewDark() {
|
||||
JetchatTheme(isDarkTheme = true) {
|
||||
JetchatAppBar(title = { Text("Preview!") })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import xyz.magicalbits.smsremote.R
|
||||
|
||||
@Composable
|
||||
fun JetchatIcon(contentDescription: String?, modifier: Modifier = Modifier) {
|
||||
val semantics = if (contentDescription != null) {
|
||||
Modifier.semantics {
|
||||
this.contentDescription = contentDescription
|
||||
this.role = Role.Image
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(modifier = modifier.then(semantics)) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_jetchat_back),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primaryContainer,
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_jetchat_front),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Message>, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Box(modifier = modifier) {
|
||||
|
||||
val authorMe = stringResource(id = R.string.author_me)
|
||||
LazyColumn(
|
||||
reverseLayout = true,
|
||||
state = scrollState,
|
||||
modifier = Modifier
|
||||
.testTag(ConversationTestTag)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
for (index in messages.indices) {
|
||||
val prevAuthor = messages.getOrNull(index - 1)?.author
|
||||
val nextAuthor = messages.getOrNull(index + 1)?.author
|
||||
val content = messages[index]
|
||||
val isFirstMessageByAuthor = prevAuthor != content.author
|
||||
val isLastMessageByAuthor = nextAuthor != content.author
|
||||
|
||||
// Hardcode day dividers for simplicity
|
||||
if (index == messages.size - 1) {
|
||||
item {
|
||||
DayHeader("20 Aug")
|
||||
}
|
||||
} else if (index == 2) {
|
||||
item {
|
||||
DayHeader("Today")
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Message(
|
||||
onAuthorClick = { name -> navigateToProfile(name) },
|
||||
msg = content,
|
||||
isUserMe = content.author == authorMe,
|
||||
isFirstMessageByAuthor = isFirstMessageByAuthor,
|
||||
isLastMessageByAuthor = isLastMessageByAuthor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Jump to bottom button shows up when user scrolls past a threshold.
|
||||
// Convert to pixels:
|
||||
val jumpThreshold = with(LocalDensity.current) {
|
||||
JumpToBottomThreshold.toPx()
|
||||
}
|
||||
|
||||
// Show the button if the first visible item is not the first one or if the offset is
|
||||
// greater than the threshold.
|
||||
val jumpToBottomButtonEnabled by remember {
|
||||
derivedStateOf {
|
||||
scrollState.firstVisibleItemIndex != 0 ||
|
||||
scrollState.firstVisibleItemScrollOffset > jumpThreshold
|
||||
}
|
||||
}
|
||||
|
||||
JumpToBottom(
|
||||
// Only show if the scroller is not at the bottom
|
||||
enabled = jumpToBottomButtonEnabled,
|
||||
onClicked = {
|
||||
scope.launch {
|
||||
scrollState.animateScrollToItem(0)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Message(
|
||||
onAuthorClick: (String) -> Unit,
|
||||
msg: Message,
|
||||
isUserMe: Boolean,
|
||||
isFirstMessageByAuthor: Boolean,
|
||||
isLastMessageByAuthor: Boolean,
|
||||
) {
|
||||
val borderColor = if (isUserMe) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
|
||||
val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier
|
||||
Row(modifier = spaceBetweenAuthors) {
|
||||
if (isLastMessageByAuthor) {
|
||||
// Avatar
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { onAuthorClick(msg.author) })
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(42.dp)
|
||||
.border(1.5.dp, borderColor, CircleShape)
|
||||
.border(3.dp, MaterialTheme.colorScheme.surface, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.align(Alignment.Top),
|
||||
painter = painterResource(id = msg.authorImage),
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
// Space under avatar
|
||||
Spacer(modifier = Modifier.width(74.dp))
|
||||
}
|
||||
AuthorAndTextMessage(
|
||||
msg = msg,
|
||||
isUserMe = isUserMe,
|
||||
isFirstMessageByAuthor = isFirstMessageByAuthor,
|
||||
isLastMessageByAuthor = isLastMessageByAuthor,
|
||||
authorClicked = onAuthorClick,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthorAndTextMessage(
|
||||
msg: Message,
|
||||
isUserMe: Boolean,
|
||||
isFirstMessageByAuthor: Boolean,
|
||||
isLastMessageByAuthor: Boolean,
|
||||
authorClicked: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (isLastMessageByAuthor) {
|
||||
AuthorNameTimestamp(msg)
|
||||
}
|
||||
ChatItemBubble(msg, isUserMe, authorClicked = authorClicked)
|
||||
if (isFirstMessageByAuthor) {
|
||||
// Last bubble before next author
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
} else {
|
||||
// Between bubbles
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthorNameTimestamp(msg: Message) {
|
||||
// Combine author and timestamp for a11y.
|
||||
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
|
||||
Text(
|
||||
text = msg.author,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.alignBy(LastBaseline)
|
||||
.paddingFrom(LastBaseline, after = 8.dp), // Space to 1st bubble
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = msg.timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.alignBy(LastBaseline),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
|
||||
|
||||
@Composable
|
||||
fun DayHeader(dayString: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
.height(16.dp),
|
||||
) {
|
||||
DayHeaderLine()
|
||||
Text(
|
||||
text = dayString,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
DayHeaderLine()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.DayHeaderLine() {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) {
|
||||
|
||||
val backgroundBubbleColor = if (isUserMe) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
|
||||
Column {
|
||||
Surface(
|
||||
color = backgroundBubbleColor,
|
||||
shape = ChatBubbleShape,
|
||||
) {
|
||||
ClickableMessage(
|
||||
message = message,
|
||||
isUserMe = isUserMe,
|
||||
authorClicked = authorClicked,
|
||||
)
|
||||
}
|
||||
|
||||
message.image?.let {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Surface(
|
||||
color = backgroundBubbleColor,
|
||||
shape = ChatBubbleShape,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(it),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.size(160.dp),
|
||||
contentDescription = stringResource(id = R.string.attached_image),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
val styledMessage = messageFormatter(
|
||||
text = message.content,
|
||||
primary = isUserMe,
|
||||
)
|
||||
|
||||
ClickableText(
|
||||
text = styledMessage,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
onClick = {
|
||||
styledMessage
|
||||
.getStringAnnotations(start = it, end = it)
|
||||
.firstOrNull()
|
||||
?.let { annotation ->
|
||||
when (annotation.tag) {
|
||||
SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item)
|
||||
SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ConversationPreview() {
|
||||
JetchatTheme {
|
||||
ConversationContent(
|
||||
uiState = exampleUiState,
|
||||
navigateToProfile = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ChannelBarPrev() {
|
||||
JetchatTheme {
|
||||
ChannelNameBar(channelName = "composers", channelMembers = 52)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DayHeaderPrev() {
|
||||
DayHeader("Aug 6")
|
||||
}
|
||||
|
||||
private val JumpToBottomThreshold = 56.dp
|
||||
+85
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.conversation
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import xyz.magicalbits.smsremote.R
|
||||
|
||||
class ConversationUiState(
|
||||
var channelName: String,
|
||||
val channelMembers: Int,
|
||||
initialMessages: List<Message>,
|
||||
) {
|
||||
private val _messages: MutableList<Message> = initialMessages.toMutableStateList()
|
||||
val messages: List<Message> = _messages
|
||||
|
||||
fun addMessage(msg: Message) {
|
||||
_messages.add(0, msg) // Add to the beginning of the list
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class Message(
|
||||
val author: String,
|
||||
val content: String,
|
||||
val timestamp: String,
|
||||
val image: Int? = null,
|
||||
val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.conversation
|
||||
|
||||
import androidx.compose.animation.core.animateDp
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import xyz.magicalbits.smsremote.R
|
||||
|
||||
private enum class Visibility {
|
||||
VISIBLE,
|
||||
GONE,
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a button that lets the user scroll to the bottom.
|
||||
*/
|
||||
@Composable
|
||||
fun JumpToBottom(enabled: Boolean, onClicked: () -> Unit, modifier: Modifier = Modifier) {
|
||||
// Show Jump to Bottom button
|
||||
val transition = updateTransition(
|
||||
if (enabled) Visibility.VISIBLE else Visibility.GONE,
|
||||
label = "JumpToBottom visibility animation",
|
||||
)
|
||||
val bottomOffset by transition.animateDp(label = "JumpToBottom offset animation") {
|
||||
if (it == Visibility.GONE) {
|
||||
(-32).dp
|
||||
} else {
|
||||
32.dp
|
||||
}
|
||||
}
|
||||
if (bottomOffset > 0.dp) {
|
||||
ExtendedFloatingActionButton(
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_arrow_downward),
|
||||
modifier = Modifier.height(18.dp),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(id = R.string.jumpBottom))
|
||||
},
|
||||
onClick = onClicked,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = modifier
|
||||
.offset(x = 0.dp, y = -bottomOffset)
|
||||
.height(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun JumpToBottomPreview() {
|
||||
JumpToBottom(enabled = true, onClicked = {})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.conversation
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Regex containing the syntax tokens
|
||||
val symbolPattern by lazy {
|
||||
Regex("""(https?://[^\s\t\n]+)|(`[^`]+`)|(@\w+)|(\*[\w]+\*)|(_[\w]+_)|(~[\w]+~)""")
|
||||
}
|
||||
|
||||
// Accepted annotations for the ClickableTextWrapper
|
||||
enum class SymbolAnnotationType {
|
||||
PERSON,
|
||||
LINK,
|
||||
}
|
||||
typealias StringAnnotation = AnnotatedString.Range<String>
|
||||
|
||||
// Pair returning styled content and annotation for ClickableText when matching syntax token
|
||||
typealias SymbolAnnotation = Pair<AnnotatedString, StringAnnotation?>
|
||||
|
||||
/**
|
||||
* Format a message following Markdown-lite syntax
|
||||
* | @username -> bold, primary color and clickable element
|
||||
* | http(s)://... -> clickable link, opening it into the browser
|
||||
* | *bold* -> bold
|
||||
* | _italic_ -> italic
|
||||
* | ~strikethrough~ -> strikethrough
|
||||
* | `MyClass.myMethod` -> inline code styling
|
||||
*
|
||||
* @param text contains message to be parsed
|
||||
* @return AnnotatedString with annotations used inside the ClickableText wrapper
|
||||
*/
|
||||
@Composable
|
||||
fun messageFormatter(
|
||||
text: String,
|
||||
primary: Boolean,
|
||||
): AnnotatedString {
|
||||
val tokens = symbolPattern.findAll(text)
|
||||
|
||||
return buildAnnotatedString {
|
||||
var cursorPosition = 0
|
||||
|
||||
val codeSnippetBackground =
|
||||
if (primary) {
|
||||
MaterialTheme.colorScheme.secondary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
for (token in tokens) {
|
||||
append(text.slice(cursorPosition until token.range.first))
|
||||
|
||||
val (annotatedString, stringAnnotation) =
|
||||
getSymbolAnnotation(
|
||||
matchResult = token,
|
||||
colorScheme = MaterialTheme.colorScheme,
|
||||
primary = primary,
|
||||
codeSnippetBackground = codeSnippetBackground,
|
||||
)
|
||||
append(annotatedString)
|
||||
|
||||
if (stringAnnotation != null) {
|
||||
val (item, start, end, tag) = stringAnnotation
|
||||
addStringAnnotation(tag = tag, start = start, end = end, annotation = item)
|
||||
}
|
||||
|
||||
cursorPosition = token.range.last + 1
|
||||
}
|
||||
|
||||
if (!tokens.none()) {
|
||||
append(text.slice(cursorPosition..text.lastIndex))
|
||||
} else {
|
||||
append(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map regex matches found in a message with supported syntax symbols
|
||||
*
|
||||
* @param matchResult is a regex result matching our syntax symbols
|
||||
* @return pair of AnnotatedString with annotation (optional) used inside the ClickableText wrapper
|
||||
*/
|
||||
private fun getSymbolAnnotation(
|
||||
matchResult: MatchResult,
|
||||
colorScheme: ColorScheme,
|
||||
primary: Boolean,
|
||||
codeSnippetBackground: Color,
|
||||
): SymbolAnnotation =
|
||||
when (matchResult.value.first()) {
|
||||
'@' -> {
|
||||
SymbolAnnotation(
|
||||
AnnotatedString(
|
||||
text = matchResult.value,
|
||||
spanStyle =
|
||||
SpanStyle(
|
||||
color = if (primary) colorScheme.inversePrimary else colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
),
|
||||
StringAnnotation(
|
||||
item = matchResult.value.substring(1),
|
||||
start = matchResult.range.first,
|
||||
end = matchResult.range.last,
|
||||
tag = SymbolAnnotationType.PERSON.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
'*' -> {
|
||||
SymbolAnnotation(
|
||||
AnnotatedString(
|
||||
text = matchResult.value.trim('*'),
|
||||
spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
'_' -> {
|
||||
SymbolAnnotation(
|
||||
AnnotatedString(
|
||||
text = matchResult.value.trim('_'),
|
||||
spanStyle = SpanStyle(fontStyle = FontStyle.Italic),
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
'~' -> {
|
||||
SymbolAnnotation(
|
||||
AnnotatedString(
|
||||
text = matchResult.value.trim('~'),
|
||||
spanStyle = SpanStyle(textDecoration = TextDecoration.LineThrough),
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
'`' -> {
|
||||
SymbolAnnotation(
|
||||
AnnotatedString(
|
||||
text = matchResult.value.trim('`'),
|
||||
spanStyle =
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
background = codeSnippetBackground,
|
||||
baselineShift = BaselineShift(0.2f),
|
||||
),
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
'h' -> {
|
||||
SymbolAnnotation(
|
||||
AnnotatedString(
|
||||
text = matchResult.value,
|
||||
spanStyle =
|
||||
SpanStyle(
|
||||
color = if (primary) colorScheme.inversePrimary else colorScheme.primary,
|
||||
),
|
||||
),
|
||||
StringAnnotation(
|
||||
item = matchResult.value,
|
||||
start = matchResult.range.first,
|
||||
end = matchResult.range.last,
|
||||
tag = SymbolAnnotationType.LINK.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
SymbolAnnotation(AnnotatedString(matchResult.value), null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.conversation
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.paddingFrom
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusTarget
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.FirstBaseline
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.SemanticsPropertyKey
|
||||
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
|
||||
import xyz.magicalbits.smsremote.R
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
enum class InputSelector {
|
||||
NONE,
|
||||
MAP,
|
||||
DM,
|
||||
EMOJI,
|
||||
PHONE,
|
||||
PICTURE,
|
||||
}
|
||||
|
||||
enum class EmojiStickerSelector {
|
||||
EMOJI,
|
||||
STICKER,
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun UserInputPreview() {
|
||||
UserInput(onMessageSent = {})
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun UserInput(onMessageSent: (String) -> Unit, modifier: Modifier = Modifier, resetScroll: () -> Unit = {}) {
|
||||
var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) }
|
||||
val dismissKeyboard = { currentInputSelector = InputSelector.NONE }
|
||||
|
||||
// Intercept back navigation if there's a InputSelector visible
|
||||
if (currentInputSelector != InputSelector.NONE) {
|
||||
BackHandler(onBack = dismissKeyboard)
|
||||
}
|
||||
|
||||
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue())
|
||||
}
|
||||
|
||||
// Used to decide if the keyboard should be shown
|
||||
var textFieldFocusState by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(tonalElevation = 2.dp, contentColor = MaterialTheme.colorScheme.secondary) {
|
||||
Column(modifier = modifier) {
|
||||
UserInputText(
|
||||
textFieldValue = textState,
|
||||
onTextChanged = { textState = it },
|
||||
// Only show the keyboard if there's no input selector and text field has focus
|
||||
keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState,
|
||||
// Close extended selector if text field receives focus
|
||||
onTextFieldFocused = { focused ->
|
||||
if (focused) {
|
||||
currentInputSelector = InputSelector.NONE
|
||||
resetScroll()
|
||||
}
|
||||
textFieldFocusState = focused
|
||||
},
|
||||
onMessageSent = {
|
||||
onMessageSent(textState.text)
|
||||
// Reset text field and close keyboard
|
||||
textState = TextFieldValue()
|
||||
// Move scroll to bottom
|
||||
resetScroll()
|
||||
},
|
||||
focusState = textFieldFocusState,
|
||||
)
|
||||
UserInputSelector(
|
||||
onSelectorChange = { currentInputSelector = it },
|
||||
sendMessageEnabled = textState.text.isNotBlank(),
|
||||
onMessageSent = {
|
||||
onMessageSent(textState.text)
|
||||
// Reset text field and close keyboard
|
||||
textState = TextFieldValue()
|
||||
// Move scroll to bottom
|
||||
resetScroll()
|
||||
dismissKeyboard()
|
||||
},
|
||||
currentInputSelector = currentInputSelector,
|
||||
)
|
||||
SelectorExpanded(
|
||||
onCloseRequested = dismissKeyboard,
|
||||
onTextAdded = { textState = textState.addText(it) },
|
||||
currentSelector = currentInputSelector,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TextFieldValue.addText(newString: String): TextFieldValue {
|
||||
val newText = this.text.replaceRange(
|
||||
this.selection.start,
|
||||
this.selection.end,
|
||||
newString,
|
||||
)
|
||||
val newSelection = TextRange(
|
||||
start = newText.length,
|
||||
end = newText.length,
|
||||
)
|
||||
|
||||
return this.copy(text = newText, selection = newSelection)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectorExpanded(currentSelector: InputSelector, onCloseRequested: () -> Unit, onTextAdded: (String) -> Unit) {
|
||||
if (currentSelector == InputSelector.NONE) return
|
||||
|
||||
// Request focus to force the TextField to lose it
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
// If the selector is shown, always request focus to trigger a TextField.onFocusChange.
|
||||
SideEffect {
|
||||
if (currentSelector == InputSelector.EMOJI) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(tonalElevation = 8.dp) {
|
||||
when (currentSelector) {
|
||||
InputSelector.EMOJI -> EmojiSelector(onTextAdded, focusRequester)
|
||||
InputSelector.DM -> NotAvailablePopup(onCloseRequested)
|
||||
InputSelector.PICTURE -> FunctionalityNotAvailablePanel()
|
||||
InputSelector.MAP -> FunctionalityNotAvailablePanel()
|
||||
InputSelector.PHONE -> FunctionalityNotAvailablePanel()
|
||||
InputSelector.NONE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FunctionalityNotAvailablePanel() {
|
||||
AnimatedVisibility(
|
||||
visibleState = remember { MutableTransitionState(false).apply { targetState = true } },
|
||||
enter = expandHorizontally() + fadeIn(),
|
||||
exit = shrinkHorizontally() + fadeOut(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.height(320.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.not_available),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.not_available_subtitle),
|
||||
modifier = Modifier.paddingFrom(FirstBaseline, before = 32.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserInputSelector(
|
||||
onSelectorChange: (InputSelector) -> Unit,
|
||||
sendMessageEnabled: Boolean,
|
||||
onMessageSent: () -> Unit,
|
||||
currentInputSelector: InputSelector,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.height(72.dp)
|
||||
.wrapContentHeight()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
InputSelectorButton(
|
||||
onClick = { onSelectorChange(InputSelector.EMOJI) },
|
||||
icon = painterResource(id = R.drawable.ic_mood),
|
||||
selected = currentInputSelector == InputSelector.EMOJI,
|
||||
description = stringResource(id = R.string.emoji_selector_bt_desc),
|
||||
)
|
||||
InputSelectorButton(
|
||||
onClick = { onSelectorChange(InputSelector.DM) },
|
||||
icon = painterResource(id = R.drawable.ic_alternate_email),
|
||||
selected = currentInputSelector == InputSelector.DM,
|
||||
description = stringResource(id = R.string.dm_desc),
|
||||
)
|
||||
InputSelectorButton(
|
||||
onClick = { onSelectorChange(InputSelector.PICTURE) },
|
||||
icon = painterResource(id = R.drawable.ic_insert_photo),
|
||||
selected = currentInputSelector == InputSelector.PICTURE,
|
||||
description = stringResource(id = R.string.attach_photo_desc),
|
||||
)
|
||||
InputSelectorButton(
|
||||
onClick = { onSelectorChange(InputSelector.MAP) },
|
||||
icon = painterResource(id = R.drawable.ic_place),
|
||||
selected = currentInputSelector == InputSelector.MAP,
|
||||
description = stringResource(id = R.string.map_selector_desc),
|
||||
)
|
||||
InputSelectorButton(
|
||||
onClick = { onSelectorChange(InputSelector.PHONE) },
|
||||
icon = painterResource(id = R.drawable.ic_duo),
|
||||
selected = currentInputSelector == InputSelector.PHONE,
|
||||
description = stringResource(id = R.string.videochat_desc),
|
||||
)
|
||||
|
||||
val border = if (!sendMessageEnabled) {
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
val disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
|
||||
|
||||
val buttonColors = ButtonDefaults.buttonColors(
|
||||
disabledContainerColor = Color.Transparent,
|
||||
disabledContentColor = disabledContentColor,
|
||||
)
|
||||
|
||||
// Send button
|
||||
Button(
|
||||
modifier = Modifier.height(36.dp),
|
||||
enabled = sendMessageEnabled,
|
||||
onClick = onMessageSent,
|
||||
colors = buttonColors,
|
||||
border = border,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.send),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputSelectorButton(
|
||||
onClick: () -> Unit,
|
||||
icon: androidx.compose.ui.graphics.painter.Painter,
|
||||
description: String,
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val backgroundModifier = if (selected) {
|
||||
Modifier.background(
|
||||
color = LocalContentColor.current,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.then(backgroundModifier),
|
||||
) {
|
||||
val tint = if (selected) {
|
||||
contentColorFor(backgroundColor = LocalContentColor.current)
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
}
|
||||
Icon(
|
||||
icon,
|
||||
tint = tint,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(56.dp),
|
||||
contentDescription = description,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotAvailablePopup(onDismissed: () -> Unit) {
|
||||
FunctionalityNotAvailablePopup(onDismissed)
|
||||
}
|
||||
|
||||
val KeyboardShownKey = SemanticsPropertyKey<Boolean>("KeyboardShownKey")
|
||||
var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
private fun UserInputText(
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
onTextChanged: (TextFieldValue) -> Unit,
|
||||
textFieldValue: TextFieldValue,
|
||||
keyboardShown: Boolean,
|
||||
onTextFieldFocused: (Boolean) -> Unit,
|
||||
onMessageSent: (String) -> Unit,
|
||||
focusState: Boolean,
|
||||
) {
|
||||
val swipeOffset = remember { mutableStateOf(0f) }
|
||||
var isRecordingMessage by remember { mutableStateOf(false) }
|
||||
val a11ylabel = stringResource(id = R.string.textfield_desc)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = isRecordingMessage,
|
||||
label = "text-field",
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
) { recording ->
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (recording) {
|
||||
RecordingIndicator { swipeOffset.value }
|
||||
} else {
|
||||
UserInputTextField(
|
||||
textFieldValue,
|
||||
onTextChanged,
|
||||
onTextFieldFocused,
|
||||
keyboardType,
|
||||
focusState,
|
||||
onMessageSent,
|
||||
Modifier.fillMaxWidth().semantics {
|
||||
contentDescription = a11ylabel
|
||||
keyboardShownProperty = keyboardShown
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.UserInputTextField(
|
||||
textFieldValue: TextFieldValue,
|
||||
onTextChanged: (TextFieldValue) -> Unit,
|
||||
onTextFieldFocused: (Boolean) -> Unit,
|
||||
keyboardType: KeyboardType,
|
||||
focusState: Boolean,
|
||||
onMessageSent: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var lastFocusState by remember { mutableStateOf(false) }
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = { onTextChanged(it) },
|
||||
modifier = modifier
|
||||
.padding(start = 32.dp)
|
||||
.align(Alignment.CenterStart)
|
||||
.onFocusChanged { state ->
|
||||
if (lastFocusState != state.isFocused) {
|
||||
onTextFieldFocused(state.isFocused)
|
||||
}
|
||||
lastFocusState = state.isFocused
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
imeAction = ImeAction.Send,
|
||||
),
|
||||
keyboardActions = KeyboardActions {
|
||||
if (textFieldValue.text.isNotBlank()) onMessageSent(textFieldValue.text)
|
||||
},
|
||||
maxLines = 1,
|
||||
cursorBrush = SolidColor(LocalContentColor.current),
|
||||
textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current),
|
||||
)
|
||||
|
||||
val disableContentColor =
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
if (textFieldValue.text.isEmpty() && !focusState) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 32.dp),
|
||||
text = stringResource(R.string.textfield_hint),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(color = disableContentColor),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordingIndicator(swipeOffset: () -> Float) {
|
||||
var duration by remember { mutableStateOf(Duration.ZERO) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
duration += 1.seconds
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
|
||||
val animatedPulse = infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.2f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
tween(2000),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "pulse",
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.size(56.dp)
|
||||
.padding(24.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = animatedPulse.value
|
||||
scaleY = animatedPulse.value
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(Color.Red),
|
||||
)
|
||||
Text(
|
||||
duration.toComponents { minutes, seconds, _ ->
|
||||
val min = minutes.toString().padStart(2, '0')
|
||||
val sec = seconds.toString().padStart(2, '0')
|
||||
"$min:$sec"
|
||||
},
|
||||
Modifier.alignByBaseline(),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.alignByBaseline()
|
||||
.clipToBounds(),
|
||||
) {
|
||||
val swipeThreshold = with(LocalDensity.current) { 200.dp.toPx() }
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.graphicsLayer {
|
||||
translationX = swipeOffset() / 2
|
||||
alpha = 1 - (swipeOffset().absoluteValue / swipeThreshold)
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
text = stringResource(R.string.swipe_to_cancel_recording),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmojiSelector(onTextAdded: (String) -> Unit, focusRequester: FocusRequester) {
|
||||
var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) }
|
||||
|
||||
val a11yLabel = stringResource(id = R.string.emoji_selector_desc)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester) // Requests focus when the Emoji selector is displayed
|
||||
// Make the emoji selector focusable so it can steal focus from TextField
|
||||
.focusTarget()
|
||||
.semantics { contentDescription = a11yLabel },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
) {
|
||||
ExtendedSelectorInnerButton(
|
||||
text = stringResource(id = R.string.emojis_label),
|
||||
onClick = { selected = EmojiStickerSelector.EMOJI },
|
||||
selected = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ExtendedSelectorInnerButton(
|
||||
text = stringResource(id = R.string.stickers_label),
|
||||
onClick = { selected = EmojiStickerSelector.STICKER },
|
||||
selected = false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
EmojiTable(onTextAdded, modifier = Modifier.padding(8.dp))
|
||||
}
|
||||
}
|
||||
if (selected == EmojiStickerSelector.STICKER) {
|
||||
NotAvailablePopup(onDismissed = { selected = EmojiStickerSelector.EMOJI })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExtendedSelectorInnerButton(text: String, onClick: () -> Unit, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
val colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
||||
else Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.74f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.padding(8.dp)
|
||||
.height(36.dp),
|
||||
colors = colors,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmojiTable(onTextAdded: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
repeat(4) { x ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
repeat(EMOJI_COLUMNS) { y ->
|
||||
val emoji = emojis[x * EMOJI_COLUMNS + y]
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { onTextAdded(emoji) })
|
||||
.sizeIn(minWidth = 42.dp, minHeight = 42.dp)
|
||||
.padding(8.dp),
|
||||
text = emoji,
|
||||
style = LocalTextStyle.current.copy(
|
||||
fontSize = 18.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val EMOJI_COLUMNS = 10
|
||||
|
||||
private val emojis = listOf(
|
||||
"\ud83d\ude00", // Grinning Face
|
||||
"\ud83d\ude01", // Grinning Face With Smiling Eyes
|
||||
"\ud83d\ude02", // Face With Tears of Joy
|
||||
"\ud83d\ude03", // Smiling Face With Open Mouth
|
||||
"\ud83d\ude04", // Smiling Face With Open Mouth and Smiling Eyes
|
||||
"\ud83d\ude05", // Smiling Face With Open Mouth and Cold Sweat
|
||||
"\ud83d\ude06", // Smiling Face With Open Mouth and Tightly-Closed Eyes
|
||||
"\ud83d\ude09", // Winking Face
|
||||
"\ud83d\ude0a", // Smiling Face With Smiling Eyes
|
||||
"\ud83d\ude0b", // Face Savouring Delicious Food
|
||||
"\ud83d\ude0e", // Smiling Face With Sunglasses
|
||||
"\ud83d\ude0d", // Smiling Face With Heart-Shaped Eyes
|
||||
"\ud83d\ude18", // Face Throwing a Kiss
|
||||
"\ud83d\ude17", // Kissing Face
|
||||
"\ud83d\ude19", // Kissing Face With Smiling Eyes
|
||||
"\ud83d\ude1a", // Kissing Face With Closed Eyes
|
||||
"\u263a", // White Smiling Face
|
||||
"\ud83d\ude42", // Slightly Smiling Face
|
||||
"\ud83e\udd17", // Hugging Face
|
||||
"\ud83d\ude07", // Smiling Face With Halo
|
||||
"\ud83e\udd13", // Nerd Face
|
||||
"\ud83e\udd14", // Thinking Face
|
||||
"\ud83d\ude10", // Neutral Face
|
||||
"\ud83d\ude11", // Expressionless Face
|
||||
"\ud83d\ude36", // Face Without Mouth
|
||||
"\ud83d\ude44", // Face With Rolling Eyes
|
||||
"\ud83d\ude0f", // Smirking Face
|
||||
"\ud83d\ude23", // Persevering Face
|
||||
"\ud83d\ude25", // Disappointed but Relieved Face
|
||||
"\ud83d\ude2e", // Face With Open Mouth
|
||||
"\ud83e\udd10", // Zipper-Mouth Face
|
||||
"\ud83d\ude2f", // Hushed Face
|
||||
"\ud83d\ude2a", // Sleepy Face
|
||||
"\ud83d\ude2b", // Tired Face
|
||||
"\ud83d\ude34", // Sleeping Face
|
||||
"\ud83d\ude0c", // Relieved Face
|
||||
"\ud83d\ude1b", // Face With Stuck-Out Tongue
|
||||
"\ud83d\ude1c", // Face With Stuck-Out Tongue and Winking Eye
|
||||
"\ud83d\ude1d", // Face With Stuck-Out Tongue and Tightly-Closed Eyes
|
||||
"\ud83d\ude12", // Unamused Face
|
||||
"\ud83d\ude13", // Face With Cold Sweat
|
||||
"\ud83d\ude14", // Pensive Face
|
||||
"\ud83d\ude15", // Confused Face
|
||||
"\ud83d\ude43", // Upside-Down Face
|
||||
"\ud83e\udd11", // Money-Mouth Face
|
||||
"\ud83d\ude32", // Astonished Face
|
||||
"\ud83d\ude37", // Face With Medical Mask
|
||||
"\ud83e\udd12", // Face With Thermometer
|
||||
"\ud83e\udd15", // Face With Head-Bandage
|
||||
"\u2639", // White Frowning Face
|
||||
"\ud83d\ude41", // Slightly Frowning Face
|
||||
"\ud83d\ude16", // Confounded Face
|
||||
"\ud83d\ude1e", // Disappointed Face
|
||||
"\ud83d\ude1f", // Worried Face
|
||||
"\ud83d\ude24", // Face With Look of Triumph
|
||||
"\ud83d\ude22", // Crying Face
|
||||
"\ud83d\ude2d", // Loudly Crying Face
|
||||
"\ud83d\ude26", // Frowning Face With Open Mouth
|
||||
"\ud83d\ude27", // Anguished Face
|
||||
"\ud83d\ude28", // Fearful Face
|
||||
"\ud83d\ude29", // Weary Face
|
||||
"\ud83d\ude2c", // Grimacing Face
|
||||
"\ud83d\ude30", // Face With Open Mouth and Cold Sweat
|
||||
"\ud83d\ude31", // Face Screaming in Fear
|
||||
"\ud83d\ude33", // Flushed Face
|
||||
"\ud83d\ude35", // Dizzy Face
|
||||
"\ud83d\ude21", // Pouting Face
|
||||
"\ud83d\ude20", // Angry Face
|
||||
"\ud83d\ude08", // Smiling Face With Horns
|
||||
"\ud83d\udc7f", // Imp
|
||||
"\ud83d\udc79", // Japanese Ogre
|
||||
"\ud83d\udc7a", // Japanese Goblin
|
||||
"\ud83d\udc80", // Skull
|
||||
"\ud83d\udc7b", // Ghost
|
||||
"\ud83d\udc7d", // Extraterrestrial Alien
|
||||
"\ud83e\udd16", // Robot Face
|
||||
"\ud83d\udca9", // Pile of Poo
|
||||
"\ud83d\ude3a", // Smiling Cat Face With Open Mouth
|
||||
"\ud83d\ude38", // Grinning Cat Face With Smiling Eyes
|
||||
"\ud83d\ude39", // Cat Face With Tears of Joy
|
||||
"\ud83d\ude3b", // Smiling Cat Face With Heart-Shaped Eyes
|
||||
"\ud83d\ude3c", // Cat Face With Wry Smile
|
||||
"\ud83d\ude3d", // Kissing Cat Face With Closed Eyes
|
||||
"\ud83d\ude40", // Weary Cat Face
|
||||
"\ud83d\ude3f", // Crying Cat Face
|
||||
"\ud83d\ude3e", // Pouting Cat Face
|
||||
"\ud83d\udc66", // Boy
|
||||
"\ud83d\udc67", // Girl
|
||||
"\ud83d\udc68", // Man
|
||||
"\ud83d\udc69", // Woman
|
||||
"\ud83d\udc74", // Older Man
|
||||
"\ud83d\udc75", // Older Woman
|
||||
"\ud83d\udc76", // Baby
|
||||
"\ud83d\udc71", // Person With Blond Hair
|
||||
"\ud83d\udc6e", // Police Officer
|
||||
"\ud83d\udc72", // Man With Gua Pi Mao
|
||||
"\ud83d\udc73", // Man With Turban
|
||||
"\ud83d\udc77", // Construction Worker
|
||||
"\u26d1", // Helmet With White Cross
|
||||
"\ud83d\udc78", // Princess
|
||||
"\ud83d\udc82", // Guardsman
|
||||
"\ud83d\udd75", // Sleuth or Spy
|
||||
"\ud83c\udf85", // Father Christmas
|
||||
"\ud83d\udc70", // Bride With Veil
|
||||
"\ud83d\udc7c", // Baby Angel
|
||||
"\ud83d\udc86", // Face Massage
|
||||
"\ud83d\udc87", // Haircut
|
||||
"\ud83d\ude4d", // Person Frowning
|
||||
"\ud83d\ude4e", // Person With Pouting Face
|
||||
"\ud83d\ude45", // Face With No Good Gesture
|
||||
"\ud83d\ude46", // Face With OK Gesture
|
||||
"\ud83d\udc81", // Information Desk Person
|
||||
"\ud83d\ude4b", // Happy Person Raising One Hand
|
||||
"\ud83d\ude47", // Person Bowing Deeply
|
||||
"\ud83d\ude4c", // Person Raising Both Hands in Celebration
|
||||
"\ud83d\ude4f", // Person With Folded Hands
|
||||
"\ud83d\udde3", // Speaking Head in Silhouette
|
||||
"\ud83d\udc64", // Bust in Silhouette
|
||||
"\ud83d\udc65", // Busts in Silhouette
|
||||
"\ud83d\udeb6", // Pedestrian
|
||||
"\ud83c\udfc3", // Runner
|
||||
"\ud83d\udc6f", // Woman With Bunny Ears
|
||||
"\ud83d\udc83", // Dancer
|
||||
"\ud83d\udd74", // Man in Business Suit Levitating
|
||||
"\ud83d\udc6b", // Man and Woman Holding Hands
|
||||
"\ud83d\udc6c", // Two Men Holding Hands
|
||||
"\ud83d\udc6d", // Two Women Holding Hands
|
||||
"\ud83d\udc8f", // Kiss
|
||||
)
|
||||
@@ -0,0 +1,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")
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
package xyz.magicalbits.smsremote.device
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import xyz.magicalbits.smsremote.R
|
||||
import xyz.magicalbits.smsremote.components.baselineHeight
|
||||
|
||||
@Composable
|
||||
fun DeviceScreen(
|
||||
deviceData: DeviceScreenState,
|
||||
nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
||||
onPhoneNumberClicked: (String) -> Unit,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(nestedScrollInteropConnection),
|
||||
) {
|
||||
Surface {
|
||||
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(horizontal = 16.dp)) {
|
||||
Name(deviceData, Modifier.baselineHeight(32.dp))
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
deviceData.phoneNumbers.forEach {
|
||||
PhoneNumber(
|
||||
value = it,
|
||||
modifier = Modifier
|
||||
.baselineHeight(24.dp)
|
||||
.clickable(onClick = { onPhoneNumberClicked(it) }),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Name(
|
||||
deviceData: DeviceScreenState,
|
||||
modifier: Modifier
|
||||
) {
|
||||
Text(
|
||||
text = deviceData.name,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhoneNumber(
|
||||
value: String,
|
||||
modifier: Modifier,
|
||||
style: TextStyle
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
modifier = modifier,
|
||||
style = style
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeviceError() {
|
||||
Text(stringResource(R.string.device_error))
|
||||
}
|
||||
@@ -0,0 +1,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<ComposeView>(R.id.toolbar_compose_view).apply {
|
||||
setContent {
|
||||
JetchatTheme {
|
||||
JetchatAppBar(
|
||||
// Reset the minimum bounds that are passed to the root of a compose tree
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
onNavIconPressed = { activityViewModel.openDrawer() },
|
||||
title = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rootView.findViewById<ComposeView>(R.id.profile_compose_view).apply {
|
||||
setContent {
|
||||
val deviceData by viewModel.deviceData.observeAsState()
|
||||
val nestedScrollInteropConnection = rememberNestedScrollInteropConnection()
|
||||
|
||||
JetchatTheme {
|
||||
if (deviceData == null) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<DeviceScreenState>()
|
||||
val deviceData: LiveData<DeviceScreenState> = _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<String>
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.profile
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import xyz.magicalbits.smsremote.data.colleagueProfile
|
||||
import xyz.magicalbits.smsremote.data.meProfile
|
||||
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||
|
||||
@Preview(widthDp = 340, name = "340 width - Me")
|
||||
@Composable
|
||||
fun ProfilePreview340() {
|
||||
JetchatTheme {
|
||||
ProfileScreen(meProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 480, name = "480 width - Me")
|
||||
@Composable
|
||||
fun ProfilePreview480Me() {
|
||||
JetchatTheme {
|
||||
ProfileScreen(meProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 480, name = "480 width - Other")
|
||||
@Composable
|
||||
fun ProfilePreview480Other() {
|
||||
JetchatTheme {
|
||||
ProfileScreen(colleagueProfile)
|
||||
}
|
||||
}
|
||||
@Preview(widthDp = 340, name = "340 width - Me - Dark")
|
||||
@Composable
|
||||
fun ProfilePreview340MeDark() {
|
||||
JetchatTheme(isDarkTheme = true) {
|
||||
ProfileScreen(meProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 480, name = "480 width - Me - Dark")
|
||||
@Composable
|
||||
fun ProfilePreview480MeDark() {
|
||||
JetchatTheme(isDarkTheme = true) {
|
||||
ProfileScreen(meProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 480, name = "480 width - Other - Dark")
|
||||
@Composable
|
||||
fun ProfilePreview480OtherDark() {
|
||||
JetchatTheme(isDarkTheme = true) {
|
||||
ProfileScreen(colleagueProfile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.profile
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
|
||||
import xyz.magicalbits.smsremote.R
|
||||
import xyz.magicalbits.smsremote.components.AnimatingFabContent
|
||||
import xyz.magicalbits.smsremote.components.baselineHeight
|
||||
import xyz.magicalbits.smsremote.data.colleagueProfile
|
||||
import xyz.magicalbits.smsremote.data.meProfile
|
||||
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||
|
||||
//@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
userData: ProfileScreenState,
|
||||
nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
||||
) {
|
||||
var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
|
||||
if (functionalityNotAvailablePopupShown) {
|
||||
FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(nestedScrollInteropConnection)
|
||||
.systemBarsPadding(),
|
||||
) {
|
||||
Surface {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
ProfileHeader(
|
||||
scrollState,
|
||||
userData,
|
||||
this@BoxWithConstraints.maxHeight,
|
||||
)
|
||||
UserInfoFields(userData, this@BoxWithConstraints.maxHeight)
|
||||
}
|
||||
}
|
||||
|
||||
val fabExtended by remember { derivedStateOf { scrollState.value == 0 } }
|
||||
ProfileFab(
|
||||
extended = fabExtended,
|
||||
userIsMe = userData.isMe(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
// Offsets the FAB to compensate for CoordinatorLayout collapsing behaviour
|
||||
.offset(y = ((-100).dp)),
|
||||
onFabClicked = { functionalityNotAvailablePopupShown = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserInfoFields(userData: ProfileScreenState, containerHeight: Dp) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
NameAndPosition(userData)
|
||||
|
||||
ProfileProperty(stringResource(R.string.display_name), userData.displayName)
|
||||
|
||||
ProfileProperty(stringResource(R.string.status), userData.status)
|
||||
|
||||
ProfileProperty(stringResource(R.string.twitter), userData.twitter, isLink = true)
|
||||
|
||||
userData.timeZone?.let {
|
||||
ProfileProperty(stringResource(R.string.timezone), userData.timeZone)
|
||||
}
|
||||
|
||||
// Add a spacer that always shows part (320.dp) of the fields list regardless of the device,
|
||||
// in order to always leave some content at the top.
|
||||
Spacer(Modifier.height((containerHeight - 320.dp).coerceAtLeast(0.dp)))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NameAndPosition(userData: ProfileScreenState) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Name(
|
||||
userData,
|
||||
modifier = Modifier.baselineHeight(32.dp),
|
||||
)
|
||||
Position(
|
||||
userData,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 20.dp)
|
||||
.baselineHeight(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Name(userData: ProfileScreenState, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = userData.name,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Position(userData: ProfileScreenState, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = userData.position,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileHeader(scrollState: ScrollState, data: ProfileScreenState, containerHeight: Dp) {
|
||||
val offset = (scrollState.value / 2)
|
||||
val offsetDp = with(LocalDensity.current) { offset.toDp() }
|
||||
|
||||
data.photo?.let {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.heightIn(max = containerHeight / 2)
|
||||
.fillMaxWidth()
|
||||
// TODO: Update to use offset to avoid recomposition
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
top = offsetDp,
|
||||
end = 16.dp,
|
||||
)
|
||||
.clip(CircleShape),
|
||||
painter = painterResource(id = it),
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileProperty(label: String, value: String, isLink: Boolean = false) {
|
||||
Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) {
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.baselineHeight(24.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val style = if (isLink) {
|
||||
MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
MaterialTheme.typography.bodyLarge
|
||||
}
|
||||
Text(
|
||||
text = value,
|
||||
modifier = Modifier.baselineHeight(24.dp),
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileError() {
|
||||
Text(stringResource(R.string.profile_error))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileFab(extended: Boolean, userIsMe: Boolean, modifier: Modifier = Modifier, onFabClicked: () -> Unit = { }) {
|
||||
key(userIsMe) {
|
||||
// Prevent multiple invocations to execute during composition
|
||||
FloatingActionButton(
|
||||
onClick = onFabClicked,
|
||||
modifier = modifier
|
||||
.padding(16.dp)
|
||||
.navigationBarsPadding()
|
||||
.height(48.dp)
|
||||
.widthIn(min = 48.dp),
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
) {
|
||||
AnimatingFabContent(
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = if (userIsMe) R.drawable.ic_create else R.drawable.ic_chat),
|
||||
contentDescription = stringResource(
|
||||
if (userIsMe) R.string.edit_profile else R.string.message,
|
||||
),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = if (userIsMe) R.string.edit_profile else R.string.message,
|
||||
),
|
||||
)
|
||||
},
|
||||
extended = extended,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 640, heightDp = 360)
|
||||
@Composable
|
||||
fun ConvPreviewLandscapeMeDefault() {
|
||||
JetchatTheme {
|
||||
ProfileScreen(meProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 360, heightDp = 480)
|
||||
@Composable
|
||||
fun ConvPreviewPortraitMeDefault() {
|
||||
JetchatTheme {
|
||||
ProfileScreen(meProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 360, heightDp = 480)
|
||||
@Composable
|
||||
fun ConvPreviewPortraitOtherDefault() {
|
||||
JetchatTheme {
|
||||
ProfileScreen(colleagueProfile)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ProfileFabPreview() {
|
||||
JetchatTheme {
|
||||
ProfileFab(extended = true, userIsMe = false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.profile
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
|
||||
import xyz.magicalbits.smsremote.MainViewModel
|
||||
import xyz.magicalbits.smsremote.R
|
||||
import xyz.magicalbits.smsremote.components.JetchatAppBar
|
||||
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||
|
||||
class ProfileFragment : Fragment() {
|
||||
private val viewModel: ProfileViewModel by viewModels()
|
||||
private val activityViewModel: MainViewModel by activityViewModels()
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
// Consider using safe args plugin
|
||||
val userId = arguments?.getString("userId")
|
||||
viewModel.setUserId(userId)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false)
|
||||
|
||||
rootView.findViewById<ComposeView>(R.id.toolbar_compose_view).apply {
|
||||
setContent {
|
||||
var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
|
||||
if (functionalityNotAvailablePopupShown) {
|
||||
FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
|
||||
}
|
||||
|
||||
JetchatTheme {
|
||||
JetchatAppBar(
|
||||
// Reset the minimum bounds that are passed to the root of a compose tree
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
onNavIconPressed = { activityViewModel.openDrawer() },
|
||||
title = { },
|
||||
actions = {
|
||||
// More icon
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_more_vert),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable(onClick = {
|
||||
functionalityNotAvailablePopupShown = true
|
||||
})
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||
.height(24.dp),
|
||||
contentDescription = stringResource(id = R.string.more_options),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rootView.findViewById<ComposeView>(R.id.profile_compose_view).apply {
|
||||
setContent {
|
||||
val userData by viewModel.userData.observeAsState()
|
||||
val nestedScrollInteropConnection = rememberNestedScrollInteropConnection()
|
||||
|
||||
JetchatTheme {
|
||||
if (userData == null) {
|
||||
ProfileError()
|
||||
} else {
|
||||
ProfileScreen(
|
||||
userData = userData!!,
|
||||
nestedScrollInteropConnection = nestedScrollInteropConnection,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rootView
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.profile
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import xyz.magicalbits.smsremote.data.colleagueProfile
|
||||
import xyz.magicalbits.smsremote.data.meProfile
|
||||
|
||||
class ProfileViewModel : ViewModel() {
|
||||
private var userId: String = ""
|
||||
|
||||
fun setUserId(newUserId: String?) {
|
||||
if (newUserId != userId) {
|
||||
userId = newUserId ?: meProfile.userId
|
||||
}
|
||||
// Workaround for simplicity
|
||||
_userData.value =
|
||||
if (userId == meProfile.userId || userId == meProfile.displayName) {
|
||||
meProfile
|
||||
} else {
|
||||
colleagueProfile
|
||||
}
|
||||
}
|
||||
|
||||
private val _userData = MutableLiveData<ProfileScreenState>()
|
||||
val userData: LiveData<ProfileScreenState> = _userData
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ProfileScreenState(
|
||||
val userId: String,
|
||||
@param:DrawableRes val photo: Int?,
|
||||
val name: String,
|
||||
val status: String,
|
||||
val displayName: String,
|
||||
val position: String,
|
||||
val twitter: String = "",
|
||||
val timeZone: String?, // Null if me
|
||||
val commonChannels: String?, // Null if me
|
||||
) {
|
||||
fun isMe() = userId == meProfile.userId
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Blue10 = Color(0xFF000F5E)
|
||||
val Blue20 = Color(0xFF001E92)
|
||||
val Blue30 = Color(0xFF002ECC)
|
||||
val Blue40 = Color(0xFF1546F6)
|
||||
val Blue80 = Color(0xFFB8C3FF)
|
||||
val Blue90 = Color(0xFFDDE1FF)
|
||||
|
||||
val DarkBlue10 = Color(0xFF00036B)
|
||||
val DarkBlue20 = Color(0xFF000BA6)
|
||||
val DarkBlue30 = Color(0xFF1026D3)
|
||||
val DarkBlue40 = Color(0xFF3648EA)
|
||||
val DarkBlue80 = Color(0xFFBBC2FF)
|
||||
val DarkBlue90 = Color(0xFFDEE0FF)
|
||||
|
||||
val Yellow10 = Color(0xFF261900)
|
||||
val Yellow20 = Color(0xFF402D00)
|
||||
val Yellow30 = Color(0xFF5C4200)
|
||||
val Yellow40 = Color(0xFF7A5900)
|
||||
val Yellow80 = Color(0xFFFABD1B)
|
||||
val Yellow90 = Color(0xFFFFDE9C)
|
||||
|
||||
val Red10 = Color(0xFF410001)
|
||||
val Red20 = Color(0xFF680003)
|
||||
val Red30 = Color(0xFF930006)
|
||||
val Red40 = Color(0xFFBA1B1B)
|
||||
val Red80 = Color(0xFFFFB4A9)
|
||||
val Red90 = Color(0xFFFFDAD4)
|
||||
|
||||
val Grey10 = Color(0xFF191C1D)
|
||||
val Grey20 = Color(0xFF2D3132)
|
||||
val Grey80 = Color(0xFFC4C7C7)
|
||||
val Grey90 = Color(0xFFE0E3E3)
|
||||
val Grey95 = Color(0xFFEFF1F1)
|
||||
val Grey99 = Color(0xFFFBFDFD)
|
||||
|
||||
val BlueGrey30 = Color(0xFF45464F)
|
||||
val BlueGrey50 = Color(0xFF767680)
|
||||
val BlueGrey60 = Color(0xFF90909A)
|
||||
val BlueGrey80 = Color(0xFFC6C5D0)
|
||||
val BlueGrey90 = Color(0xFFE2E1EC)
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.theme
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
val JetchatDarkColorScheme = darkColorScheme(
|
||||
primary = Blue80,
|
||||
onPrimary = Blue20,
|
||||
primaryContainer = Blue30,
|
||||
onPrimaryContainer = Blue90,
|
||||
inversePrimary = Blue40,
|
||||
secondary = DarkBlue80,
|
||||
onSecondary = DarkBlue20,
|
||||
secondaryContainer = DarkBlue30,
|
||||
onSecondaryContainer = DarkBlue90,
|
||||
tertiary = Yellow80,
|
||||
onTertiary = Yellow20,
|
||||
tertiaryContainer = Yellow30,
|
||||
onTertiaryContainer = Yellow90,
|
||||
error = Red80,
|
||||
onError = Red20,
|
||||
errorContainer = Red30,
|
||||
onErrorContainer = Red90,
|
||||
background = Grey10,
|
||||
onBackground = Grey90,
|
||||
surface = Grey10,
|
||||
onSurface = Grey80,
|
||||
inverseSurface = Grey90,
|
||||
inverseOnSurface = Grey20,
|
||||
surfaceVariant = BlueGrey30,
|
||||
onSurfaceVariant = BlueGrey80,
|
||||
outline = BlueGrey60,
|
||||
)
|
||||
|
||||
val JetchatLightColorScheme = lightColorScheme(
|
||||
primary = Blue40,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Blue90,
|
||||
onPrimaryContainer = Blue10,
|
||||
inversePrimary = Blue80,
|
||||
secondary = DarkBlue40,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = DarkBlue90,
|
||||
onSecondaryContainer = DarkBlue10,
|
||||
tertiary = Yellow40,
|
||||
onTertiary = Color.White,
|
||||
tertiaryContainer = Yellow90,
|
||||
onTertiaryContainer = Yellow10,
|
||||
error = Red40,
|
||||
onError = Color.White,
|
||||
errorContainer = Red90,
|
||||
onErrorContainer = Red10,
|
||||
background = Grey99,
|
||||
onBackground = Grey10,
|
||||
surface = Grey99,
|
||||
onSurface = Grey10,
|
||||
inverseSurface = Grey20,
|
||||
inverseOnSurface = Grey95,
|
||||
surfaceVariant = BlueGrey90,
|
||||
onSurfaceVariant = BlueGrey30,
|
||||
outline = BlueGrey50,
|
||||
)
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
@Composable
|
||||
fun JetchatTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), isDynamicColor: Boolean = true, content: @Composable () -> Unit) {
|
||||
val dynamicColor = isDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
val myColorScheme = when {
|
||||
dynamicColor && isDarkTheme -> {
|
||||
dynamicDarkColorScheme(LocalContext.current)
|
||||
}
|
||||
|
||||
dynamicColor && !isDarkTheme -> {
|
||||
dynamicLightColorScheme(LocalContext.current)
|
||||
}
|
||||
|
||||
isDarkTheme -> JetchatDarkColorScheme
|
||||
|
||||
else -> JetchatLightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = myColorScheme,
|
||||
typography = JetchatTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.googlefonts.Font
|
||||
import androidx.compose.ui.text.googlefonts.GoogleFont
|
||||
import androidx.compose.ui.unit.sp
|
||||
import xyz.magicalbits.smsremote.R
|
||||
|
||||
val provider =
|
||||
GoogleFont.Provider(
|
||||
providerAuthority = "com.google.android.gms.fonts",
|
||||
providerPackage = "com.google.android.gms",
|
||||
certificates = R.array.com_google_android_gms_fonts_certs,
|
||||
)
|
||||
|
||||
val MontserratFont = GoogleFont(name = "Montserrat")
|
||||
|
||||
val KarlaFont = GoogleFont(name = "Karla")
|
||||
|
||||
val MontserratFontFamily =
|
||||
FontFamily(
|
||||
Font(googleFont = MontserratFont, fontProvider = provider),
|
||||
Font(resId = R.font.montserrat_regular),
|
||||
Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.Light),
|
||||
Font(resId = R.font.montserrat_light, weight = FontWeight.Light),
|
||||
Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.Medium),
|
||||
Font(resId = R.font.montserrat_medium, weight = FontWeight.Medium),
|
||||
Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.SemiBold),
|
||||
Font(resId = R.font.montserrat_semibold, weight = FontWeight.SemiBold),
|
||||
)
|
||||
|
||||
val KarlaFontFamily =
|
||||
FontFamily(
|
||||
Font(googleFont = KarlaFont, fontProvider = provider),
|
||||
Font(resId = R.font.karla_regular),
|
||||
Font(googleFont = KarlaFont, fontProvider = provider, weight = FontWeight.Bold),
|
||||
Font(resId = R.font.karla_bold, weight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
val JetchatTypography =
|
||||
Typography(
|
||||
displayLarge =
|
||||
TextStyle(
|
||||
fontFamily = MontserratFontFamily,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
displayMedium =
|
||||
TextStyle(
|
||||
fontFamily = MontserratFontFamily,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = 45.sp,
|
||||
lineHeight = 52.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
displaySmall =
|
||||
TextStyle(
|
||||
fontFamily = MontserratFontFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
headlineLarge =
|
||||
TextStyle(
|
||||
fontFamily = MontserratFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
headlineMedium =
|
||||
TextStyle(
|
||||
fontFamily = MontserratFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
headlineSmall =
|
||||
TextStyle(
|
||||
fontFamily = MontserratFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleLarge =
|
||||
TextStyle(
|
||||
fontFamily = MontserratFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleMedium =
|
||||
TextStyle(
|
||||
fontFamily = MontserratFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp,
|
||||
),
|
||||
titleSmall =
|
||||
TextStyle(
|
||||
fontFamily = KarlaFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
bodyLarge =
|
||||
TextStyle(
|
||||
fontFamily = KarlaFontFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp,
|
||||
),
|
||||
bodyMedium =
|
||||
TextStyle(
|
||||
fontFamily = MontserratFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp,
|
||||
),
|
||||
bodySmall =
|
||||
TextStyle(
|
||||
fontFamily = KarlaFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp,
|
||||
),
|
||||
labelLarge =
|
||||
TextStyle(
|
||||
fontFamily = MontserratFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
labelMedium =
|
||||
TextStyle(
|
||||
fontFamily = MontserratFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
labelSmall =
|
||||
TextStyle(
|
||||
fontFamily = MontserratFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import xyz.magicalbits.smsremote.data.unreadMessages
|
||||
import xyz.magicalbits.smsremote.widget.composables.MessagesWidget
|
||||
|
||||
class JetChatWidget : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(
|
||||
context: Context,
|
||||
id: GlanceId,
|
||||
) {
|
||||
provideContent {
|
||||
GlanceTheme {
|
||||
MessagesWidget(unreadMessages.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.widget
|
||||
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
class WidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget
|
||||
get() = JetChatWidget()
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.widget.composables
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.actionStartActivity
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.components.Scaffold
|
||||
import androidx.glance.appwidget.components.TitleBar
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.text.Text
|
||||
import xyz.magicalbits.smsremote.NavActivity
|
||||
import xyz.magicalbits.smsremote.R
|
||||
import xyz.magicalbits.smsremote.conversation.Message
|
||||
import xyz.magicalbits.smsremote.widget.theme.JetChatGlanceTextStyles
|
||||
import xyz.magicalbits.smsremote.widget.theme.JetchatGlanceColorScheme
|
||||
|
||||
@Composable
|
||||
fun MessagesWidget(messages: List<Message>) {
|
||||
Scaffold(titleBar = {
|
||||
TitleBar(
|
||||
startIcon = ImageProvider(R.drawable.ic_jetchat),
|
||||
iconColor = null,
|
||||
title = LocalContext.current.getString(R.string.messages_widget_title),
|
||||
)
|
||||
}, backgroundColor = JetchatGlanceColorScheme.colors.background) {
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
messages.forEach {
|
||||
item {
|
||||
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
MessageItem(it)
|
||||
Spacer(modifier = GlanceModifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageItem(message: Message) {
|
||||
Column(modifier = GlanceModifier.clickable(actionStartActivity<NavActivity>()).fillMaxWidth()) {
|
||||
Text(
|
||||
text = message.author,
|
||||
style = JetChatGlanceTextStyles.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = message.content,
|
||||
style = JetChatGlanceTextStyles.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun MessageItemPreview() {
|
||||
MessageItem(Message("John", "This is a preview of the message Item", "8:02PM"))
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun WidgetPreview() {
|
||||
MessagesWidget(listOf(Message("John", "This is a preview of the message Item", "8:02PM")))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.widget.theme
|
||||
|
||||
import androidx.glance.material3.ColorProviders
|
||||
import xyz.magicalbits.smsremote.theme.JetchatDarkColorScheme
|
||||
import xyz.magicalbits.smsremote.theme.JetchatLightColorScheme
|
||||
|
||||
object JetchatGlanceColorScheme {
|
||||
val colors = ColorProviders(
|
||||
light = JetchatLightColorScheme,
|
||||
dark = JetchatDarkColorScheme,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.widget.theme
|
||||
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.TextStyle
|
||||
|
||||
object JetChatGlanceTextStyles {
|
||||
|
||||
val titleMedium = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = JetchatGlanceColorScheme.colors.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
val bodyMedium = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = JetchatGlanceColorScheme.colors.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user