2026-08-01 15:00:58 +05:30

610 lines
19 KiB
Kotlin

package com.bitchat.android.ui
import androidx.annotation.DrawableRes
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import com.bitchat.android.core.ui.icon.BitChatIcon
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.util.sdp
import com.bitchat.android.util.ssp
/**
* Building blocks for the redesigned About sheet.
*
* Kept in a separate file from [AboutSheet] because the sheet itself is mostly wiring for
* preferences, whereas these are pure presentation.
*/
/** Horizontal inset shared by every About section, so cards and labels align to one grid. */
internal val AboutHorizontalPadding = 20.dp
/** Card corner radius for grouped rows. */
internal val AboutCardShape = RoundedCornerShape(16.dp)
/** Leading icon column in settings-style sheet rows. */
internal val SheetRowLeadingSlot = 22.dp
internal val SheetRowLeadingGutter = 16.dp
internal val SheetRowHorizontal = 16.dp
internal val SheetRowVertical = 13.dp
/**
* Exact height of a people-list row.
*
* Fixed rather than derived from content: these lists reorder themselves constantly, and a row
* whose height depends on its content makes the whole card change height every time the order
* changes. Equals the leading glyph plus [SheetRowVertical] above and below.
*/
internal val SheetRowHeight = SheetRowLeadingSlot + SheetRowVertical * 2
internal val SheetRowDividerInset = SheetRowHorizontal + SheetRowLeadingSlot + SheetRowLeadingGutter
/** Selection indicator sized for [SheetRowLeadingSlot]. */
internal val SheetRowSelectedDot = 12.dp
/**
* Two top-level views of the sheet: what the app is and how to drive it, versus the knobs.
*/
enum class AboutTab {
Info,
Settings,
}
/**
* Small uppercase section label, e.g. `SETTINGS`.
*
* Uppercasing happens here rather than in the string resource so translators supply natural
* sentence case and locales without a case distinction are unaffected.
*/
@Composable
internal fun AboutSectionLabel(
text: String,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
Text(
text = text.uppercase(),
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 0.8.sp,
color = palette.textTertiary,
modifier = modifier.padding(start = AboutHorizontalPadding, top = 24.dp, bottom = 8.dp)
)
}
/**
* Icon + title on one line, optional short subtitle beneath. Used by location / network sheets.
*/
@Composable
internal fun SheetIconSectionHeader(
@DrawableRes iconRes: Int,
title: String,
subtitle: String? = null,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
painter = painterResource(iconRes),
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(22.dp)
)
Text(
text = title,
fontSize = 17.sp,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold,
color = colorScheme.primary
)
}
if (!subtitle.isNullOrBlank()) {
Text(
text = subtitle,
fontSize = 12.sp,
lineHeight = 17.sp,
fontFamily = BitchatFontFamily,
color = colorScheme.onSurfaceVariant
)
}
}
}
/** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */
@Composable
internal fun SheetCardDivider() {
val colorScheme = MaterialTheme.colorScheme
HorizontalDivider(
modifier = Modifier.padding(start = SheetRowDividerInset),
thickness = 1.dp,
color = colorScheme.outlineVariant
)
}
/**
* Centered app identity block: logo, wordmark, tagline, version.
*/
@Composable
internal fun AboutHero(
versionName: String,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = BitChatIcon,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.app_name),
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 40.sp,
// Monospace at display size leaves too much air between glyphs; pull it in slightly
// so the wordmark reads as a single unit.
letterSpacing = (-0.5).sp,
color = colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.about_tagline),
fontFamily = BitchatFontFamily,
fontSize = 16.sp,
color = colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.version_prefix, versionName),
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = palette.textTertiary
)
}
}
/**
* Two-up tab bar with a sliding underline indicator.
*
* The indicator animates its offset rather than cross-fading two static bars, which is what
* makes the switch feel physically connected to the tap.
*/
@Composable
internal fun AboutTabBar(
selected: AboutTab,
onSelect: (AboutTab) -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val density = LocalDensity.current
var rowWidth by remember { mutableStateOf(0.dp) }
val tabWidth = rowWidth / 2
val indicatorOffset by animateDpAsState(
targetValue = if (selected == AboutTab.Info) 0.dp else tabWidth,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "aboutTabIndicator"
)
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.onSizeChanged { size ->
rowWidth = with(density) { size.width.toDp() }
}
) {
Row(modifier = Modifier.fillMaxWidth()) {
AboutTabLabel(
text = stringResource(R.string.about_tab_info),
isSelected = selected == AboutTab.Info,
onClick = { onSelect(AboutTab.Info) },
modifier = Modifier.weight(1f)
)
AboutTabLabel(
text = stringResource(R.string.about_tab_settings),
isSelected = selected == AboutTab.Settings,
onClick = { onSelect(AboutTab.Settings) },
modifier = Modifier.weight(1f)
)
}
Box(modifier = Modifier.fillMaxWidth()) {
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
Box(
modifier = Modifier
// Lambda overload: the offset is animated every frame, and the non-lambda
// version would invalidate composition rather than just layout.
.offset { IntOffset(x = indicatorOffset.roundToPx(), y = 0) }
.width(tabWidth)
.height(2.dp)
.background(colorScheme.primary)
)
}
}
}
@Composable
private fun AboutTabLabel(
text: String,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val color by animateColorAsState(
targetValue = if (isSelected) colorScheme.primary else colorScheme.onSurfaceVariant,
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
label = "aboutTabLabelColor"
)
Box(
modifier = modifier
.height(44.dp)
.clickable(onClickLabel = text) { onClick() },
contentAlignment = Alignment.Center
) {
Text(
text = text.uppercase(),
fontFamily = BitchatFontFamily,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 0.8.sp,
color = color
)
}
}
/** One line of the "How To Use" list: an icon plus a single instruction. */
@Composable
private fun AboutInstructionRow(
@DrawableRes iconRes: Int,
text: String
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
painter = painterResource(iconRes),
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 1.dp)
.size(22.dp)
)
Text(
text = text,
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
lineHeight = 20.sp,
color = colorScheme.onSurface
)
}
}
/**
* The "How To Use" tab: a short, scannable list of the gestures that are not self-evident.
*/
@Composable
internal fun AboutHowToUseSection(modifier: Modifier = Modifier) {
val colorScheme = MaterialTheme.colorScheme
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.about_how_to_use_heading),
fontFamily = BitchatFontFamily,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = colorScheme.primary,
modifier = Modifier.padding(
start = AboutHorizontalPadding,
top = 20.dp,
bottom = 8.dp
)
)
AboutInstructionRow(
iconRes = R.drawable.ic_spec_person,
text = stringResource(R.string.about_howto_nickname)
)
AboutInstructionRow(
iconRes = R.drawable.ic_spec_globe,
text = stringResource(R.string.about_howto_channels)
)
AboutInstructionRow(
iconRes = R.drawable.ic_spec_people,
text = stringResource(R.string.about_howto_people)
)
AboutInstructionRow(
iconRes = R.drawable.ic_spec_bookmark_outline,
text = stringResource(R.string.about_howto_bookmark)
)
AboutInstructionRow(
iconRes = R.drawable.ic_spec_mention,
text = stringResource(R.string.about_howto_mention)
)
AboutInstructionRow(
iconRes = R.drawable.ic_spec_command,
text = stringResource(R.string.about_howto_commands)
)
AboutInstructionRow(
iconRes = R.drawable.ic_spec_waveform,
text = stringResource(R.string.about_howto_panic)
)
}
}
/**
* Capability list, laid out like [AboutHowToUseSection]: flat rows, no card surface or dividers.
*/
@Composable
internal fun AboutFeatureCard(modifier: Modifier = Modifier) {
val features = listOf(
Triple(
R.drawable.ic_spec_wifi_off,
R.string.about_offline_mesh_title,
R.string.about_offline_mesh_desc
),
Triple(
R.drawable.ic_spec_lock,
R.string.about_e2e_title,
R.string.about_e2e_desc
),
Triple(
R.drawable.ic_spec_globe,
R.string.about_online_geohash_title,
R.string.about_online_geohash_desc
),
Triple(
R.drawable.ic_spec_eye_off,
R.string.about_no_tracking_title,
R.string.about_no_tracking_desc
),
)
Column(modifier = modifier.fillMaxWidth()) {
features.forEach { (icon, titleRes, descRes) ->
AboutFeatureRow(
iconRes = icon,
title = stringResource(titleRes),
subtitle = stringResource(descRes)
)
}
}
}
@Composable
private fun AboutFeatureRow(
@DrawableRes iconRes: Int,
title: String,
subtitle: String
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
painter = painterResource(iconRes),
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 1.dp)
.size(22.dp)
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = title,
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
lineHeight = 20.sp,
color = colorScheme.onSurface
)
Text(
text = subtitle,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
lineHeight = 17.sp,
color = colorScheme.onSurfaceVariant
)
}
}
}
/**
* Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle.
*/
@Composable
internal fun BitchatBadge(
text: String,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.background(colorScheme.primary.copy(alpha = 0.15f), RoundedCornerShape(4.dp))
.padding(horizontal = 5.sdp(), vertical = 2.sdp())
) {
Text(
text = text.uppercase(),
fontFamily = BitchatFontFamily,
fontSize = 9.ssp(),
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.ssp(),
color = colorScheme.primary
)
}
}
// MARK: - Shared bottom-sheet primitives
/** Horizontal inset for sheet content. Cards inset from here; labels align to the same edge. */
internal val SheetHorizontalPadding: Dp
@Composable get() = 16.sdp()
/**
* Section divider label used across the sheets, e.g. `BOOKMARKED`, `NEARBY`, `ON LOCATION`.
*/
@Composable
internal fun SheetSectionLabel(
text: String,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
Text(
text = text.uppercase(),
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 0.8.sp,
color = palette.textTertiary,
modifier = modifier
.fillMaxWidth()
.padding(
start = SheetHorizontalPadding + 14.dp,
end = SheetHorizontalPadding,
top = 20.dp,
bottom = 6.dp
)
)
}
/**
* Circular tinted badge holding a section's icon, used at the top of the sheets.
*/
@Composable
internal fun SheetHeaderBadge(
icon: ImageVector,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.size(44.dp)
.background(colorScheme.surface, androidx.compose.foundation.shape.CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(26.dp)
)
}
}
/**
* Full-width destructive action, e.g. `REMOVE LOCATION ACCESS`.
*
* Tinted fill plus an outline rather than a solid red button: the action is legitimate but
* rarely wanted, and a solid red block would dominate the sheet.
*/
@Composable
internal fun SheetDestructiveButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isDestructive: Boolean = true
) {
val colorScheme = MaterialTheme.colorScheme
val accent = if (isDestructive) colorScheme.error else colorScheme.primary
Surface(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.height(44.dp),
shape = RoundedCornerShape(10.dp),
color = accent.copy(alpha = 0.10f),
border = BorderStroke(1.dp, accent.copy(alpha = 0.30f))
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = text.uppercase(),
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 0.8.sp,
color = accent
)
}
}
}