mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-10-18 07:33:01 +00:00
610 lines
19 KiB
Kotlin
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
|
|
)
|
|
}
|
|
}
|
|
}
|