callebtc 7025009788
ui: complete the redesign — palette, top bar, composer, About, and a motion pass (#774)
* 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
2026-07-27 17:33:11 +02:00

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
)
}
}