This commit is contained in:
2026-05-19 00:10:38 +02:00
parent db2290ba14
commit c68787cd01
93 changed files with 5855 additions and 0 deletions
@@ -0,0 +1,154 @@
/*
* Copyright 2026 MagicalBits
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package xyz.magicalbits.smsremote.components
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.util.lerp
import kotlin.math.roundToInt
/**
* A layout that shows an icon and a text element used as the content for a FAB that extends with
* an animation.
*/
@Composable
fun AnimatingFabContent(
icon: @Composable () -> Unit,
text: @Composable () -> Unit,
modifier: Modifier = Modifier,
extended: Boolean = true,
) {
val currentState = if (extended) ExpandableFabStates.Extended else ExpandableFabStates.Collapsed
val transition = updateTransition(currentState, "fab_transition")
val textOpacity by transition.animateFloat(
transitionSpec = {
if (targetState == ExpandableFabStates.Collapsed) {
tween(
easing = LinearEasing,
durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames
)
} else {
tween(
easing = LinearEasing,
delayMillis = (transitionDuration / 3f).roundToInt(), // 4 / 12 frames
durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames
)
}
},
label = "fab_text_opacity",
) { state ->
if (state == ExpandableFabStates.Collapsed) {
0f
} else {
1f
}
}
val fabWidthFactor by transition.animateFloat(
transitionSpec = {
if (targetState == ExpandableFabStates.Collapsed) {
tween(
easing = FastOutSlowInEasing,
durationMillis = transitionDuration,
)
} else {
tween(
easing = FastOutSlowInEasing,
durationMillis = transitionDuration,
)
}
},
label = "fab_width_factor",
) { state ->
if (state == ExpandableFabStates.Collapsed) {
0f
} else {
1f
}
}
// Deferring reads using lambdas instead of Floats here can improve performance,
// preventing recompositions.
IconAndTextRow(
icon,
text,
{ textOpacity },
{ fabWidthFactor },
modifier = modifier,
)
}
@Composable
private fun IconAndTextRow(
icon: @Composable () -> Unit,
text: @Composable () -> Unit,
opacityProgress: () -> Float, // Lambdas instead of Floats, to defer read
widthProgress: () -> Float,
modifier: Modifier,
) {
Layout(
modifier = modifier,
content = {
icon()
Box(modifier = Modifier.graphicsLayer { alpha = opacityProgress() }) {
text()
}
},
) { measurables, constraints ->
val iconPlaceable = measurables[0].measure(constraints)
val textPlaceable = measurables[1].measure(constraints)
val height = constraints.maxHeight
// FAB has an aspect ratio of 1 so the initial width is the height
val initialWidth = height.toFloat()
// Use it to get the padding
val iconPadding = (initialWidth - iconPlaceable.width) / 2f
// The full width will be : padding + icon + padding + text + padding
val expandedWidth = iconPlaceable.width + textPlaceable.width + iconPadding * 3
// Apply the animation factor to go from initialWidth to fullWidth
val width = lerp(initialWidth, expandedWidth, widthProgress())
layout(width.roundToInt(), height) {
iconPlaceable.place(
iconPadding.roundToInt(),
constraints.maxHeight / 2 - iconPlaceable.height / 2,
)
textPlaceable.place(
(iconPlaceable.width + iconPadding * 2).roundToInt(),
constraints.maxHeight / 2 - textPlaceable.height / 2,
)
}
}
}
private enum class ExpandableFabStates { Collapsed, Extended }
private const val transitionDuration = 200
@@ -0,0 +1,61 @@
/*
* Copyright 2026 MagicalBits
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package xyz.magicalbits.smsremote.components
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.layout.LayoutModifier
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
/**
* Applied to a Text, it sets the distance between the top and the first baseline. It
* also makes the bottom of the element coincide with the last baseline of the text.
*
* _______________
* | | ↑
* | | | heightFromBaseline
* |Hello, World!| ↓
* ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
*
* This modifier can be used to distribute multiple text elements using a certain distance between
* baselines.
*/
data class BaselineHeightModifier(
val heightFromBaseline: Dp,
) : LayoutModifier {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
val textPlaceable = measurable.measure(constraints)
val firstBaseline = textPlaceable[FirstBaseline]
val lastBaseline = textPlaceable[LastBaseline]
val height = heightFromBaseline.roundToPx() + lastBaseline - firstBaseline
return layout(constraints.maxWidth, height) {
val topY = heightFromBaseline.roundToPx() - firstBaseline
textPlaceable.place(0, topY)
}
}
}
fun Modifier.baselineHeight(heightFromBaseline: Dp): Modifier = this.then(BaselineHeightModifier(heightFromBaseline))
@@ -0,0 +1,79 @@
/*
* Copyright 2026 MagicalBits
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package xyz.magicalbits.smsremote.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import xyz.magicalbits.smsremote.R
import xyz.magicalbits.smsremote.theme.JetchatTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JetchatAppBar(
modifier: Modifier = Modifier,
scrollBehavior: TopAppBarScrollBehavior? = null,
onNavIconPressed: () -> Unit = { },
title: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
) {
CenterAlignedTopAppBar(
modifier = modifier,
actions = actions,
title = title,
scrollBehavior = scrollBehavior,
navigationIcon = {
JetchatIcon(
contentDescription = stringResource(id = R.string.navigation_drawer_open),
modifier = Modifier
.size(64.dp)
.clickable(onClick = onNavIconPressed)
.padding(16.dp),
)
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun JetchatAppBarPreview() {
JetchatTheme {
JetchatAppBar(title = { Text("Preview!") })
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun JetchatAppBarPreviewDark() {
JetchatTheme(isDarkTheme = true) {
JetchatAppBar(title = { Text("Preview!") })
}
}
@@ -0,0 +1,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,
)
}
}