mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
* first pass * pass 2 * cleanup * capitalization * strings * input bar fixes * fixes * notes * nice * nicer * lists * cleanup * button * fixes * animations * Fix layout jumpiness in chat and geohash people list Three separate causes of things moving when they should not: - Chat lurched whenever a bottom sheet closed. Placement animation is meant to soften insertions and removals, but any relayout moves every item -- a sheet's text field opening the keyboard changes the chat's IME inset, and closing it changes it back. Placement animation is now armed only briefly around a real change to the message list, so items otherwise track the viewport exactly. - Anon list changed height as participants churned. Rows sized to their content, so any reorder could change the card's height; and the card sized to the live anon count, which moves constantly in a busy geohash. Rows now have an exact height, and a trimmed anon card reserves the full capped height regardless of how many are present beyond the cap. - Anons are now their own trailing section rather than a tail on each of "on location" and "teleported in", which had pushed the few recognisable names out of view twice over. Self is never grouped as an anon. Adds 7 tests covering the sectioning and the fixed-length behaviour. * Group geohash people as People and Anon Replaces the "on location" / "teleported in" / "anonymous" split with two sections: peers who announced a nickname, then the anons. Teleport state was never worth a section of its own -- every row already carries it as a distinct glyph -- and splitting on it fragmented the short list people actually read, in a channel where most participants are anonymous anyway. Self stays in the People section even when unnamed. * Key message list state per conversation Switching channels reused every piece of state in MessagesList, because none of it was keyed on which conversation was being shown: - The LazyListState carried the previous channel's scroll offset, so the new channel opened at a stale position and then corrected itself. - hasScrolledToInitialPosition and followIncomingMessages carried over, so a channel entered after scrolling up in another one did not land on its newest message at all. - The arrival tracker had never seen the incoming channel's ids, so a backlog of six or fewer messages was treated as six simultaneous arrivals and each one slid in. - previousMessageCount carried over, arming placement animation for the relayout that the switch itself caused. All of it is now keyed on a conversationKey derived the same way displayMessages is. The tracker also detects a list sharing no ids with the previous one and adopts it silently, which covers /clear and any caller that does not supply a distinct key. Adds 4 tests for wholesale replacement, including the case that the burst cap cannot catch on its own. * fix location channel layout * icon * location sheet * move location error * fix location channel lifecycle bug * remove empty lable * geist mono * timestamp no seconds * new icons * icons * cleanup * mentions * fix mentions * grouping of geohash channel list * colors * fix mention colors * Bring private and group chat headers up to the main header's layout Both conversation headers were built on TopAppBar with a centred title, a back arrow on the left and everything else crowded into the title slot, at 14sp with 14dp icons. Moving between the timeline and a conversation visibly shifted the bar's height, insets and type. Introduces ConversationHeader, built from the main header's own tokens rather than TopAppBar: same ChatHeaderHeight, same 12/8dp edge insets, leading glyph in a 44dp slot so it lands exactly where the brand mark does, same -6dp optical nudge pulling the title toward it, same 17sp label. - Drops the back button; the close action on the right is the way out. Leaving a channel outright already lives on its row in the network sheet, so it does not need a second home beside the exit. - Leading glyph is the transport: globe over the internet, wifi/bluetooth/ routed on the mesh, matching the main header's channel button. - Actions are right-aligned and unweighted -- favourite, encryption state, close -- so a long title yields space to them instead of pushing them off screen. - Private chat titles use the primary green like every other header label, rather than orange for Nostr-reachable peers. Height and edge insets now belong to each header variant instead of the ChatFloatingHeader wrapper, which was applying them a second time to the channel header. Adds nine spec icons in the existing 20x20 / 1.25-stroke language -- bluetooth, wifi, routed, close, check, warning, sync, lock_open, envelope -- so the headers and peer rows no longer mix Material glyphs into the set. * color
118 lines
4.4 KiB
Kotlin
118 lines
4.4 KiB
Kotlin
package com.bitchat.android.ui
|
|
|
|
import androidx.compose.animation.AnimatedContent
|
|
import androidx.compose.animation.SizeTransform
|
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
import androidx.compose.animation.slideInVertically
|
|
import androidx.compose.animation.slideOutVertically
|
|
import androidx.compose.animation.togetherWith
|
|
import androidx.compose.material3.LocalTextStyle
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
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.TextUnit
|
|
import com.bitchat.android.ui.theme.BitchatMotion
|
|
|
|
/**
|
|
* Counts that roll to their new value instead of snapping.
|
|
*
|
|
* Peer counts change on their own, without the user doing anything, so a hard digit swap is easy
|
|
* to miss and looks like a rendering fault when it is noticed. Sliding the digits in the
|
|
* direction the number moved — up when someone joins, down when someone leaves — conveys the
|
|
* change without needing a separate indicator.
|
|
*/
|
|
@Composable
|
|
fun AnimatedCount(
|
|
count: Int,
|
|
modifier: Modifier = Modifier,
|
|
style: TextStyle = LocalTextStyle.current,
|
|
color: Color = Color.Unspecified,
|
|
fontSize: TextUnit = TextUnit.Unspecified,
|
|
fontWeight: FontWeight? = null,
|
|
fontFamily: FontFamily? = null,
|
|
prefix: String = "",
|
|
) {
|
|
AnimatedContent(
|
|
targetState = count,
|
|
transitionSpec = {
|
|
val goingUp = targetState > initialState
|
|
(
|
|
slideInVertically(
|
|
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
|
initialOffsetY = { height -> if (goingUp) height else -height }
|
|
) + fadeIn(tween(BitchatMotion.STANDARD_MS))
|
|
).togetherWith(
|
|
slideOutVertically(
|
|
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
|
targetOffsetY = { height -> if (goingUp) -height else height }
|
|
) + fadeOut(tween(BitchatMotion.QUICK_MS))
|
|
// Clip so the outgoing digit cannot bleed past the text bounds mid-transition.
|
|
) using SizeTransform(clip = true)
|
|
},
|
|
modifier = modifier,
|
|
label = "animatedCount"
|
|
) { value ->
|
|
Text(
|
|
text = "$prefix$value",
|
|
style = style,
|
|
color = color,
|
|
fontSize = fontSize,
|
|
fontWeight = fontWeight,
|
|
fontFamily = fontFamily,
|
|
maxLines = 1
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cross-fades a label whose text embeds a count, e.g. `People (7)` or `3 people`.
|
|
*
|
|
* Used where the number is not isolated in its own composable and cannot be rolled on its own.
|
|
* The transition is keyed on [count] rather than on [text], so a label changing for some other
|
|
* reason — a locale switch, say — does not animate.
|
|
*/
|
|
@Composable
|
|
fun AnimatedCountLabel(
|
|
count: Int,
|
|
text: String,
|
|
modifier: Modifier = Modifier,
|
|
style: TextStyle = LocalTextStyle.current,
|
|
color: Color = Color.Unspecified,
|
|
fontSize: TextUnit = TextUnit.Unspecified,
|
|
fontWeight: FontWeight? = null,
|
|
fontFamily: FontFamily? = null,
|
|
) {
|
|
AnimatedContent(
|
|
targetState = count,
|
|
transitionSpec = {
|
|
fadeIn(tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing))
|
|
.togetherWith(fadeOut(tween(BitchatMotion.QUICK_MS)))
|
|
.using(SizeTransform(clip = false))
|
|
},
|
|
modifier = modifier,
|
|
label = "animatedCountLabel"
|
|
) { state ->
|
|
// Captured per state, so the outgoing copy keeps rendering the label it entered with.
|
|
// Reading `text` directly would show the *new* label on both sides of the cross-fade,
|
|
// turning the transition into a flicker between two identical strings.
|
|
val stateText = remember(state) { text }
|
|
Text(
|
|
text = stateText,
|
|
style = style,
|
|
color = color,
|
|
fontSize = fontSize,
|
|
fontWeight = fontWeight,
|
|
fontFamily = fontFamily,
|
|
maxLines = 1
|
|
)
|
|
}
|
|
}
|