mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
Merge branch 'permissionlesstech:main' into ui-dynamic-size-component
This commit is contained in:
commit
459fa27ef9
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: android-readme-screenshot-studio
|
||||
description: Create or refresh polished, high-resolution screenshots of the Bitchat Android app for README and repository showcase use. Use this skill whenever a user asks for README screenshots, app-store-like repository images, a populated mesh-chat showcase, voice-note or media conversation captures, a geohash globe image, higher-resolution Pixel captures, or a PR that adds or replaces documentation screenshots. It owns the complete workflow from latest-main isolation and deterministic synthetic fixtures through real app rendering, visual inspection, system-chrome cropping, README asset updates, clean builds, and an optional PR. Do not use it for before/after UI regression evidence, which belongs to android-ui-visual-review, or for physical mesh behavior, which belongs to mesh-lab.
|
||||
description: Create or refresh polished, high-resolution screenshots of the Bitchat Android app for README and repository showcase use. Use this skill whenever a user asks for README screenshots, app-store-like repository images, a populated mesh-chat showcase, voice-note or media conversation captures, a geohash globe image, higher-resolution Android emulator captures, or a PR that adds or replaces documentation screenshots. It owns the complete workflow from latest-main isolation and deterministic synthetic fixtures through real app rendering, visual inspection, system-chrome cropping, README asset updates, clean builds, and an optional PR. Do not use it for before/after UI regression evidence, which belongs to android-ui-visual-review, or for physical mesh behavior, which belongs to mesh-lab.
|
||||
compatibility: Requires git, gh, the Android SDK and emulator, adb, Java/Gradle, Python 3, and image inspection support. FFmpeg is useful for capture-only media preparation.
|
||||
---
|
||||
|
||||
@ -34,7 +34,11 @@ capture-only unless they request a committed asset.
|
||||
|
||||
Read [references/showcase-recipes.md](references/showcase-recipes.md) for every
|
||||
run. It contains the concrete mesh-chat and globe recipes, framing guidance,
|
||||
and the final acceptance checklist.
|
||||
the verified current-pair fast path, and the final acceptance checklist. When
|
||||
the request matches the existing README pair and the production UI has not
|
||||
materially changed, try that fast path first and then validate every visible
|
||||
result. Fall back to tracing the current implementation when an entry point,
|
||||
state model, or composition has changed.
|
||||
|
||||
When a populated screen requires a debug fixture, also read
|
||||
[../android-ui-visual-review/references/fixture-recipes.md](../android-ui-visual-review/references/fixture-recipes.md).
|
||||
@ -62,7 +66,7 @@ Before building, write a compact local matrix containing:
|
||||
- surface and navigation path;
|
||||
- chronological fixture contents;
|
||||
- expected visible top and bottom rows;
|
||||
- Pixel profile, physical resolution, logical width, theme, and locale;
|
||||
- emulator profile class, portrait orientation, theme, and locale;
|
||||
- crop policy and final asset dimensions;
|
||||
- existing asset path and README reference;
|
||||
- behaviors the static screenshot does not prove.
|
||||
@ -71,19 +75,13 @@ Use the current production UI and latest `main` interaction model. Trace the
|
||||
screen entry point and state source before adding a fixture. A beautiful capture
|
||||
of a stale or fake UI is not acceptable.
|
||||
|
||||
## Use a high-resolution Pixel canvas
|
||||
## Use a high-resolution Android canvas
|
||||
|
||||
Prefer the newest stable Android runtime installed locally and a large Pixel Pro
|
||||
profile. For the current README style, target at least:
|
||||
|
||||
- a 448 dp logical width;
|
||||
- 1344 px physical width;
|
||||
- a native portrait height near 2992 px;
|
||||
- 480 dpi or the profile's native density.
|
||||
|
||||
Pixel 9 Pro XL at 1344×2992 and 480 dpi is a known-good baseline, not a
|
||||
hard-coded requirement. A newer stable large Pixel profile is acceptable when
|
||||
its guest properties are verified.
|
||||
Prefer the newest stable Android runtime installed locally and a large,
|
||||
high-density portrait emulator profile. Reuse a previously validated capture
|
||||
profile when it remains available, but verify the guest properties and keep
|
||||
the resulting profile facts in the local capture contract rather than the
|
||||
repository or GitHub text.
|
||||
|
||||
After boot, record the guest values with an explicit emulator selector:
|
||||
|
||||
@ -127,7 +125,10 @@ envelopes look plausibly different between notes.
|
||||
|
||||
For image attachments, use a rights-safe existing asset or an explicitly
|
||||
approved synthetic source. Keep fixture media outside production source sets
|
||||
and remove every capture-only hook before committing.
|
||||
and remove every capture-only hook before committing. Compose image rows may
|
||||
remember a decoded bitmap by file path. After replacing the bytes at an
|
||||
unchanged path, relaunch the app or use a new destination path before judging
|
||||
the revised crop.
|
||||
|
||||
## Capture from the real app
|
||||
|
||||
@ -144,9 +145,17 @@ Inspect the full screenshot immediately. Check message count and order,
|
||||
nickname ownership, peer count, waveform variety, image visibility, globe
|
||||
center, grid precision, clipping, and composer placement.
|
||||
|
||||
Inject a complete timeline in one operation and allow at least two seconds of
|
||||
quiet UI time after the fixture reports success. This avoids capturing entry
|
||||
animations, incomplete placement, or media that has not finished decoding.
|
||||
|
||||
Crop only Android system chrome. Preserve Bitchat's app header, translucent
|
||||
overlap, content, and composer. Derive the crop from the observed status and
|
||||
navigation insets; do not blindly reuse pixel offsets from a different profile.
|
||||
Because the app renders edge-to-edge, app controls may extend into the reported
|
||||
navigation inset. Place the bottom crop after the final control outline and
|
||||
shadow but before the system gesture affordance; removing the entire inset can
|
||||
clip the app itself.
|
||||
Keep every final README screenshot in a matched portrait size.
|
||||
|
||||
Use image inspection after the crop. File dimensions and a successful ADB
|
||||
@ -222,8 +231,10 @@ GitHub writes require user authorization. When authorized:
|
||||
2. Commit without overriding author or committer identity.
|
||||
3. Push the `codex/` branch.
|
||||
4. Use `gh pr create` or update the existing PR.
|
||||
5. Describe the exact capture sequence, Pixel/runtime facts, synthetic fixture
|
||||
disclosure, validation commands, and limitations.
|
||||
5. Describe the exact capture sequence, synthetic fixture disclosure,
|
||||
repository-safe validation commands, and limitations. Keep emulator,
|
||||
runtime, hardware, and local-environment facts out of GitHub text whenever
|
||||
repository privacy rules classify them as machine identifiers.
|
||||
6. Verify the PR head and checks with `gh pr view` and `gh pr checks`.
|
||||
7. Merge only when the user explicitly requested it and required checks allow
|
||||
it. Prefer the repository's normal merge strategy and use `gh`.
|
||||
|
||||
@ -3,12 +3,14 @@
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"prompt": "Refresh the Bitchat README with two polished screenshots from the latest main UI: a populated mesh chat and the geohash globe centered on the Middle East without zooming. Use a large high-resolution Pixel, open a PR, and merge it after checks pass.",
|
||||
"expected_output": "The agent works in a fresh latest-main worktree, captures the real app on a verified large Pixel profile, uses deterministic synthetic state, centers the globe without changing whole-Earth scale, removes capture hooks, validates the assets, opens a PR, waits for checks, and merges only because the prompt explicitly authorizes it.",
|
||||
"prompt": "Refresh the Bitchat README with two polished screenshots from the latest main UI: a populated mesh chat and the geohash globe centered on the Middle East without zooming. Use a large high-resolution Android emulator, open a PR, and merge it after checks pass.",
|
||||
"expected_output": "The agent works in a fresh latest-main worktree, tries the documented current-pair fast path before rediscovering the fixture, captures the real app on a verified large portrait emulator profile, uses deterministic synthetic state, centers the globe without changing whole-Earth scale, removes capture hooks, validates the assets, opens a PR, waits for checks, and merges only because the prompt explicitly authorizes it.",
|
||||
"files": [],
|
||||
"expectations": [
|
||||
"Uses a fresh worktree rooted at current origin/main and leaves the original checkout untouched.",
|
||||
"Captures from the real Android UI on a verified high-resolution large Pixel profile.",
|
||||
"Captures from the real Android UI on a verified high-resolution portrait emulator profile.",
|
||||
"Uses the verified nine-row chat fixture and synthetic thky globe seed as fast starting points when the current UI still matches, while visually validating the result.",
|
||||
"Waits for fixture completion and quiet UI time, and invalidates the media path cache when replacing image bytes at the same destination.",
|
||||
"Keeps the requested Middle East focus while preserving whole-Earth zoom.",
|
||||
"Removes temporary fixtures and passes a clean debug build before committing.",
|
||||
"Uses gh for the authorized PR and merge without publishing machine identifiers."
|
||||
@ -38,6 +40,20 @@
|
||||
"Uses the PR merge-base and paired before/after capture workflow.",
|
||||
"Publishes only the requested PR comment rather than opening an unrelated screenshot PR."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"prompt": "The bottom of one or both README showcase screenshots is cropped. Correct the images without changing their content, update the screenshot skill so this does not recur, and open a new PR.",
|
||||
"expected_output": "The agent inspects both merged assets at full size, identifies every affected screen, recaptures from the real app with the existing deterministic fixtures, and treats the navigation inset as an inspection region rather than an automatic crop. The final crop preserves complete app controls while excluding the system gesture affordance, keeps the pair matched, adds a reusable edge-to-edge guardrail to the skill, validates and visually inspects both images, and opens a sanitized PR.",
|
||||
"files": [],
|
||||
"expectations": [
|
||||
"Inspects both screenshots and corrects every affected asset rather than assuming only one is cropped.",
|
||||
"Recaptures the real app with the established deterministic synthetic content instead of padding or compositing the merged PNGs.",
|
||||
"Uses accessibility bounds and full-size pixel inspection to place the bottom crop after complete app controls and before system gesture chrome.",
|
||||
"Verifies the entire chat composer border and bottom padding plus every globe action-button corner and shadow are visible.",
|
||||
"Keeps paired screenshots at identical dimensions and passes the screenshot validator and clean debug build.",
|
||||
"Updates the skill with a general edge-to-edge crop rule and publishes no device or machine identifiers."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -4,6 +4,99 @@ Use these recipes as composition guidance, then adapt them to the user's exact
|
||||
request and the current UI. The requested chronology and framing always win over
|
||||
the examples.
|
||||
|
||||
## Verified current-pair fast path
|
||||
|
||||
Use this baseline first when refreshing the existing README mesh-chat and
|
||||
geohash-globe pair without a material production UI change. It records a
|
||||
known-good capture, not a permanent UI contract: verify entry points, visible
|
||||
state, insets, and output dimensions on every run.
|
||||
|
||||
### One-pass app preparation
|
||||
|
||||
Build and install the ABI-matching debug APK. A generic `app-debug.apk` may not
|
||||
exist when the project emits ABI splits, so resolve the installed emulator ABI
|
||||
and select the matching output before searching for alternate build tasks.
|
||||
|
||||
Use normal onboarding or a temporary debug preparation command to:
|
||||
|
||||
- mark onboarding complete;
|
||||
- set the synthetic nickname `trailhead`;
|
||||
- select the production `ChatUiMode.Bubbles` presentation;
|
||||
- grant only the runtime permissions needed to reach the surface; and
|
||||
- keep BLE and Wi-Fi Aware debug transport disabled during deterministic
|
||||
rendering.
|
||||
|
||||
Launch the target Activity before injecting process-local state. A preparation
|
||||
command may use the existing `PermissionManager` and `AppStateStore` APIs, but
|
||||
must remain capture-only and be removed before the clean build.
|
||||
|
||||
### Exact chat fixture used for the current pair
|
||||
|
||||
Clear the in-memory showcase state, add four synthetic peers, and use the real
|
||||
local mesh peer ID only to mark self-authored rows. Use `solace` for the remote
|
||||
sender and insert these nine rows at one-minute intervals in one operation:
|
||||
|
||||
| Order | Sender | Content |
|
||||
|---|---|---|
|
||||
| 1 | `trailhead` | mountain image |
|
||||
| 2 | `solace` | `That view is unreal.` |
|
||||
| 3 | `trailhead` | `Worth the climb.` |
|
||||
| 4 | `solace` | `How's the signal up there?` |
|
||||
| 5 | `trailhead` | `Still holding strong.` |
|
||||
| 6 | `trailhead` | voice note |
|
||||
| 7 | `solace` | voice note |
|
||||
| 8 | `trailhead` | voice note |
|
||||
| 9 | `solace` | `👍` |
|
||||
|
||||
A fixed epoch such as `1767258000000` keeps ordering stable. The displayed
|
||||
clock text is locale- and time-zone-dependent, so validate consistency rather
|
||||
than promising a specific rendered hour.
|
||||
|
||||
Reuse the existing rights-safe mountain subject when the brief has not changed.
|
||||
A near-square source crop around 840×800 produced enough image height while
|
||||
leaving the reaction above the composer. If the crop changes, relaunch the app
|
||||
or change the cache destination path; the image row can retain the previous
|
||||
bitmap when the path is reused.
|
||||
|
||||
Generate three local, rights-safe speech clips with visibly different cadence,
|
||||
then transcode them to the app's normal M4A/AAC path. This known-good synthetic
|
||||
set used roughly 170, 220, and 145 words per minute:
|
||||
|
||||
1. `The trail is clear. I can hear you.`
|
||||
2. `Copy that. Sending one back now.`
|
||||
3. `Perfect. The mesh is still holding strong.`
|
||||
|
||||
Run each file through `AudioWaveformExtractor` and cache its 120-bin result via
|
||||
`VoiceWaveformCache`. Wait for the fixture's structured success result, then
|
||||
allow at least two additional seconds for Compose placement and media decoding
|
||||
before capture.
|
||||
|
||||
### Exact globe path used for the current pair
|
||||
|
||||
Start the production, non-exported `GeohashPickerActivity` through a temporary
|
||||
in-app debug command. Seed it with the explicitly synthetic geohash `thky`,
|
||||
wait about three seconds for the camera to settle, then invoke the production
|
||||
minus control three times with about one second between changes. The verified
|
||||
result was precision 1 with label `#t`, the whole Earth visible, and the Arabian
|
||||
Peninsula/Persian Gulf region beneath the center crosshair.
|
||||
|
||||
Treat `thky` as a fast starting point, not a substitute for inspection. Reject
|
||||
the result if geography, camera distance, grid, label, or controls differ from
|
||||
the brief. Never source the seed from device location, IP-derived location, or
|
||||
account data.
|
||||
|
||||
### Capture and crop baseline
|
||||
|
||||
Capture the full screen only after the UI has been still for at least two
|
||||
seconds. Re-observe the status and navigation insets, then remove only those
|
||||
bands while preserving all app UI. Reuse prior offsets only when the local
|
||||
profile and measured insets still match.
|
||||
|
||||
Keep both outputs at identical dimensions. Run the screenshot validator and
|
||||
inspect both images at full size. Keep raw captures, generated audio, fixture
|
||||
media, profile facts, and device output local; only the final PNG assets belong
|
||||
in the repository.
|
||||
|
||||
## Mesh-chat showcase
|
||||
|
||||
### Visual goal
|
||||
@ -129,9 +222,14 @@ Reject the capture when:
|
||||
Capture the full physical screen first. Determine the status-bar and
|
||||
navigation-bar insets from the current profile, then crop those insets only.
|
||||
|
||||
For a Pixel 9 Pro XL profile at 1344×2992, a 1344×2780 final image was a
|
||||
known-good result in one verified run. Treat those numbers as evidence, not as a
|
||||
universal crop rule.
|
||||
Treat the navigation inset as an inspection region, not an automatic crop
|
||||
amount. Bitchat uses edge-to-edge layout, so the composer's border, globe-button
|
||||
corners, or their shadows may occupy part of that region. Use accessibility
|
||||
bounds plus full-size pixel inspection to place the crop below every app
|
||||
control and above the system gesture affordance.
|
||||
|
||||
Treat previously observed crop offsets as local evidence, not as a universal
|
||||
rule or repository documentation. Re-measure when the profile changes.
|
||||
|
||||
Every paired README image should:
|
||||
|
||||
@ -152,6 +250,7 @@ Every paired README image should:
|
||||
- [ ] Waveforms were extracted from real speech audio and look distinct.
|
||||
- [ ] Final reaction is visible above the composer.
|
||||
- [ ] Header peer count and nickname are synthetic and intentional.
|
||||
- [ ] The complete composer border and bottom padding are visible.
|
||||
|
||||
### Globe
|
||||
|
||||
@ -160,6 +259,7 @@ Every paired README image should:
|
||||
- [ ] Whole Earth and grid are visible.
|
||||
- [ ] Selected cell and crosshair are legible.
|
||||
- [ ] Hint and precision controls are unobstructed.
|
||||
- [ ] Every action-button corner and shadow is fully visible.
|
||||
|
||||
### Repository
|
||||
|
||||
@ -169,4 +269,3 @@ Every paired README image should:
|
||||
- [ ] Screenshot validator passes.
|
||||
- [ ] PR text contains no machine or personal identifiers.
|
||||
- [ ] Static-capture limitations are disclosed.
|
||||
|
||||
|
||||
@ -48,6 +48,9 @@ class BitchatApplication : Application() {
|
||||
// Initialize theme preference
|
||||
ThemePreferenceManager.init(this)
|
||||
|
||||
// Initialize chat UI mode (matrix transcript vs bubbles)
|
||||
com.bitchat.android.ui.theme.ChatUiModeManager.init(this)
|
||||
|
||||
// Initialize debug preference manager (persists debug toggles)
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
|
||||
|
||||
|
||||
@ -52,6 +52,7 @@ fun AnnotatedClickableText(
|
||||
softWrap: Boolean = true,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||
) {
|
||||
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val currentOnAnnotationClick by rememberUpdatedState(onAnnotationClick)
|
||||
@ -91,6 +92,9 @@ fun AnnotatedClickableText(
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
style = style,
|
||||
onTextLayout = { layoutResult = it },
|
||||
onTextLayout = { result ->
|
||||
layoutResult = result
|
||||
onTextLayout?.invoke(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -95,9 +95,6 @@ class BluetoothGattClientManager(
|
||||
private var scanWatchdogJob: Job? = null
|
||||
private var scanDutyCycleJob: Job? = null
|
||||
|
||||
// RSSI monitoring state
|
||||
private var rssiMonitoringJob: Job? = null
|
||||
|
||||
// State management
|
||||
private var isActive = false
|
||||
|
||||
@ -133,8 +130,6 @@ class BluetoothGattClientManager(
|
||||
|
||||
connectionScope.launch {
|
||||
applyPowerProfile(powerManager.profile.value)
|
||||
// Start RSSI monitoring
|
||||
startRSSIMonitoring()
|
||||
}
|
||||
|
||||
return true
|
||||
@ -151,7 +146,6 @@ class BluetoothGattClientManager(
|
||||
if (!isActive) {
|
||||
// Idempotent stop
|
||||
stopScanning()
|
||||
stopRSSIMonitoring()
|
||||
return
|
||||
}
|
||||
|
||||
@ -167,7 +161,6 @@ class BluetoothGattClientManager(
|
||||
} catch (_: Exception) { }
|
||||
|
||||
stopScanning()
|
||||
stopRSSIMonitoring()
|
||||
Log.i(TAG, "GATT client manager stopped")
|
||||
}
|
||||
}
|
||||
@ -185,40 +178,6 @@ class BluetoothGattClientManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic RSSI monitoring for all client connections
|
||||
*/
|
||||
private fun startRSSIMonitoring() {
|
||||
rssiMonitoringJob?.cancel()
|
||||
rssiMonitoringJob = connectionScope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
// Request RSSI from all client connections
|
||||
val connectedDevices = connectionTracker.getConnectedDevices()
|
||||
connectedDevices.values.filter { it.isClient && it.gatt != null }.forEach { deviceConn ->
|
||||
try {
|
||||
deviceConn.gatt?.readRemoteRssi()
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}")
|
||||
}
|
||||
}
|
||||
delay(powerManager.profile.value.ble.rssiPollIntervalMs)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error in RSSI monitoring: ${e.message}")
|
||||
delay(powerManager.profile.value.ble.rssiPollIntervalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop RSSI monitoring
|
||||
*/
|
||||
private fun stopRSSIMonitoring() {
|
||||
rssiMonitoringJob?.cancel()
|
||||
rssiMonitoringJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Start scanning with rate limiting
|
||||
*/
|
||||
@ -627,17 +586,6 @@ class BluetoothGattClientManager(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
|
||||
val deviceAddress = gatt.device.address
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
// Update the connection tracker with new RSSI value
|
||||
connectionTracker.updateDeviceConnectionIfCurrent(deviceAddress, linkID) {
|
||||
it.copy(rssi = rssi)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Failed to read RSSI for $deviceAddress, status: $status")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@ -46,7 +46,6 @@ class PowerManager private constructor(context: Context) : LifecycleEventObserve
|
||||
val scanOnMs: Long,
|
||||
val scanOffMs: Long,
|
||||
val continuousScan: Boolean,
|
||||
val rssiPollIntervalMs: Long,
|
||||
val maxConnections: Int = 8
|
||||
)
|
||||
|
||||
@ -318,24 +317,22 @@ internal object PowerProfileResolver {
|
||||
PowerManager.BleSchedule(
|
||||
scanOnMs = 1_000L,
|
||||
scanOffMs = 29_000L,
|
||||
continuousScan = false,
|
||||
rssiPollIntervalMs = backgroundRssiInterval(batteryBand)
|
||||
continuousScan = false
|
||||
)
|
||||
isBackground ->
|
||||
PowerManager.BleSchedule(
|
||||
scanOnMs = 1_000L,
|
||||
scanOffMs = 59_000L,
|
||||
continuousScan = false,
|
||||
rssiPollIntervalMs = backgroundRssiInterval(batteryBand)
|
||||
continuousScan = false
|
||||
)
|
||||
mode == PowerManager.PowerMode.PERFORMANCE ->
|
||||
PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true, 5_000L)
|
||||
PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true)
|
||||
mode == PowerManager.PowerMode.BALANCED ->
|
||||
PowerManager.BleSchedule(8_000L, 2_000L, false, 10_000L)
|
||||
PowerManager.BleSchedule(8_000L, 2_000L, false)
|
||||
mode == PowerManager.PowerMode.POWER_SAVER ->
|
||||
PowerManager.BleSchedule(2_000L, 28_000L, false, 30_000L)
|
||||
PowerManager.BleSchedule(2_000L, 28_000L, false)
|
||||
else ->
|
||||
PowerManager.BleSchedule(1_000L, 29_000L, false, 60_000L)
|
||||
PowerManager.BleSchedule(1_000L, 29_000L, false)
|
||||
}
|
||||
|
||||
val announcementInterval = when {
|
||||
@ -407,10 +404,4 @@ internal object PowerProfileResolver {
|
||||
discoverySessionRefreshMinMs = minRefreshMinutes * 60_000L,
|
||||
discoveryStaleMs = staleMinutes * 60_000L
|
||||
)
|
||||
|
||||
private fun backgroundRssiInterval(band: PowerManager.BatteryBand): Long = when (band) {
|
||||
PowerManager.BatteryBand.NORMAL -> 60_000L
|
||||
PowerManager.BatteryBand.LOW -> 120_000L
|
||||
PowerManager.BatteryBand.CRITICAL -> 300_000L
|
||||
}
|
||||
}
|
||||
|
||||
@ -383,6 +383,7 @@ fun AboutSheet(
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_section_theme))
|
||||
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
|
||||
val chatUiMode by com.bitchat.android.ui.theme.ChatUiModeManager.modeFlow.collectAsState()
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -390,30 +391,52 @@ fun AboutSheet(
|
||||
color = colorScheme.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_system),
|
||||
selected = themePref.isSystem,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_light),
|
||||
selected = themePref.isLight,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_dark),
|
||||
selected = themePref.isDark,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_system),
|
||||
selected = themePref.isSystem,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_light),
|
||||
selected = themePref.isLight,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_dark),
|
||||
selected = themePref.isDark,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.chat_ui_bubbles),
|
||||
selected = chatUiMode.isBubbles,
|
||||
onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Bubbles) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.chat_ui_matrix),
|
||||
selected = chatUiMode.isMatrix,
|
||||
onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Matrix) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -401,6 +401,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
if (accepted) {
|
||||
messageText = TextFieldValue("")
|
||||
viewModel.setConversationDraft(selectedPrivatePeer, "")
|
||||
// Clearing the field in code does not run onMessageTextChange,
|
||||
// so the popups have to be dismissed here.
|
||||
viewModel.clearSuggestions()
|
||||
forceScrollToBottom = !forceScrollToBottom
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,11 +53,14 @@ fun getRSSIColor(rssi: Int): Color {
|
||||
}
|
||||
}
|
||||
|
||||
/** Thin space (U+2009) separating a display name from its `#abcd` disambiguation suffix. */
|
||||
internal const val SUFFIX_THIN_SPACE = " "
|
||||
|
||||
/**
|
||||
* Build the sender label shown above the first message of a group.
|
||||
*
|
||||
* Renders `@name` plus a dimmed `#abcd` suffix. The name carries a `nickname_click`
|
||||
* annotation for everyone except yourself.
|
||||
* Renders `@name` plus a dimmed `#abcd` suffix, separated by a thin space. The name carries
|
||||
* a `nickname_click` annotation for everyone except yourself.
|
||||
*/
|
||||
fun formatTextMessageSender(
|
||||
message: BitchatMessage,
|
||||
@ -97,6 +100,7 @@ fun formatTextMessageSender(
|
||||
builder.pop()
|
||||
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.append(SUFFIX_THIN_SPACE)
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = senderColor.copy(alpha = SUFFIX_ALPHA),
|
||||
@ -304,6 +308,7 @@ fun formatMessageHeaderAnnotatedString(
|
||||
builder.pop()
|
||||
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.append(SUFFIX_THIN_SPACE)
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = baseColor.copy(alpha = SUFFIX_ALPHA),
|
||||
|
||||
@ -1360,7 +1360,11 @@ class ChatViewModel(
|
||||
fun updateCommandSuggestions(input: String) {
|
||||
commandProcessor.updateCommandSuggestions(input)
|
||||
}
|
||||
|
||||
|
||||
fun clearSuggestions() {
|
||||
commandProcessor.clearSuggestions()
|
||||
}
|
||||
|
||||
fun selectCommandSuggestion(suggestion: CommandSuggestion): String {
|
||||
return commandProcessor.selectCommandSuggestion(suggestion)
|
||||
}
|
||||
|
||||
@ -465,6 +465,18 @@ class CommandProcessor(
|
||||
messageManager.addMessage(systemMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the command and mention popups. The composer clears its field in code
|
||||
* after a send, which does not run onValueChange, so the popups need an explicit
|
||||
* clear.
|
||||
*/
|
||||
fun clearSuggestions() {
|
||||
state.setShowCommandSuggestions(false)
|
||||
state.setCommandSuggestions(emptyList())
|
||||
state.setShowMentionSuggestions(false)
|
||||
state.setMentionSuggestions(emptyList())
|
||||
}
|
||||
|
||||
// MARK: - Command Autocomplete
|
||||
|
||||
fun updateCommandSuggestions(input: String) {
|
||||
|
||||
@ -1849,7 +1849,9 @@ fun PrivateChatSheet(
|
||||
onMessageTextChange = { newText ->
|
||||
messageText = newText
|
||||
viewModel.setConversationDraft(peerID, newText.text)
|
||||
viewModel.updateMentionSuggestions(newText.text)
|
||||
// Do not update the shared suggestion state here: this sheet
|
||||
// renders its own popups as hidden, so an update only leaves
|
||||
// a stale popup behind for the main composer.
|
||||
},
|
||||
onSend = {
|
||||
if (messageText.text.trim().isNotEmpty()) {
|
||||
|
||||
@ -8,7 +8,7 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
@ -16,9 +16,6 @@ import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@ -27,15 +24,19 @@ import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@ -49,6 +50,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -61,16 +63,21 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
@ -83,10 +90,12 @@ import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.ui.media.FileMessageItem
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.ChatUiModeManager
|
||||
import com.bitchat.android.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.ui.theme.MessageBodyTextStyle
|
||||
import com.bitchat.android.ui.theme.MessageSenderTextStyle
|
||||
import com.bitchat.android.ui.theme.colorForPeer
|
||||
import kotlinx.coroutines.delay
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
@ -223,6 +232,10 @@ fun MessagesList(
|
||||
mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages)
|
||||
}
|
||||
|
||||
// Collected once here so individual rows never subscribe to the preference flow; a mode
|
||||
// switch simply recomposes the list against the new layout.
|
||||
val bubbles by ChatUiModeManager.modeFlow.collectAsState()
|
||||
|
||||
// A fresh scroll position per conversation. Sharing one state meant a switch inherited the
|
||||
// previous channel's offset and then had to correct itself, which is what the jump was.
|
||||
//
|
||||
@ -352,6 +365,7 @@ fun MessagesList(
|
||||
meshService = meshService,
|
||||
mentionPeerIdentities = resolvedMentionPeerIdentities,
|
||||
showSender = !isGrouped,
|
||||
bubbles = bubbles.isBubbles,
|
||||
topSpacing = MessageGrouping.topSpacingFor(
|
||||
isGrouped = isGrouped,
|
||||
isFirstInList = originalIndex == 0
|
||||
@ -385,6 +399,7 @@ fun MessageItem(
|
||||
messages: List<BitchatMessage> = emptyList(),
|
||||
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
||||
showSender: Boolean = true,
|
||||
bubbles: Boolean = false,
|
||||
topSpacing: Dp = 0.dp,
|
||||
onNicknameClick: ((String) -> Unit)? = null,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
||||
@ -407,8 +422,9 @@ fun MessageItem(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
// Provide a small end padding for own private messages so overlay doesn't cover text
|
||||
val endPad = if (message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp
|
||||
// Provide a small end padding for own private messages so overlay doesn't cover text.
|
||||
// Bubble mode draws the status beneath the bubble instead, so no inset is needed.
|
||||
val endPad = if (!bubbles && message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp
|
||||
// Create a custom layout that combines selectable text with clickable nickname areas
|
||||
MessageTextWithClickableNicknames(
|
||||
message = message,
|
||||
@ -419,6 +435,7 @@ fun MessageItem(
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
bubbles = bubbles,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
@ -429,8 +446,9 @@ fun MessageItem(
|
||||
)
|
||||
}
|
||||
|
||||
// Delivery status for private messages (overlay, non-displacing)
|
||||
if (message.isPrivate && message.sender == currentUserNickname) {
|
||||
// Delivery status for private messages (overlay, non-displacing). Bubble mode aligns
|
||||
// own messages to the end edge where this overlay lives, so it renders below instead.
|
||||
if (!bubbles && message.isPrivate && message.sender == currentUserNickname) {
|
||||
message.deliveryStatus?.let { status ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@ -442,7 +460,24 @@ fun MessageItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Bubble mode: text messages carry the marker inline, trailing the timestamp. Media
|
||||
// rows have no inline text, so their marker stays beneath the end-aligned card.
|
||||
if (bubbles && message.type != BitchatMessageType.Message &&
|
||||
message.isPrivate && message.sender == currentUserNickname
|
||||
) {
|
||||
message.deliveryStatus?.let { status ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp, end = 4.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
DeliveryStatusIcon(status = status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Link previews removed; links are now highlighted inline and clickable within the message text
|
||||
}
|
||||
}
|
||||
@ -458,6 +493,7 @@ fun MessageItem(
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
showSender: Boolean,
|
||||
bubbles: Boolean = false,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
@ -476,6 +512,7 @@ fun MessageItem(
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
bubbles = bubbles,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
@ -494,6 +531,7 @@ fun MessageItem(
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
bubbles = bubbles,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
@ -514,7 +552,15 @@ fun MessageItem(
|
||||
}
|
||||
else -> null to null
|
||||
}
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
// Bubble mode aligns self-authored file rows to the end side, mirroring text bubbles.
|
||||
horizontalAlignment = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) {
|
||||
Alignment.End
|
||||
} else {
|
||||
Alignment.Start
|
||||
},
|
||||
) {
|
||||
// Header: nickname + timestamp line above the file, identical styling to text messages
|
||||
val headerText = formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
@ -560,7 +606,14 @@ fun MessageItem(
|
||||
null
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) {
|
||||
Arrangement.End
|
||||
} else {
|
||||
Arrangement.Start
|
||||
}
|
||||
) {
|
||||
Box {
|
||||
if (packet != null) {
|
||||
if (overrideProgress != null) {
|
||||
@ -624,6 +677,7 @@ fun MessageItem(
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
bubbles = bubbles,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier
|
||||
@ -668,6 +722,7 @@ fun MessageItem(
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
bubbles = bubbles,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
@ -687,6 +742,7 @@ internal fun TextMessageLayout(
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true,
|
||||
bubbles: Boolean = false,
|
||||
bodyContent: String = message.content,
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
@ -702,8 +758,17 @@ internal fun TextMessageLayout(
|
||||
palette = palette,
|
||||
)
|
||||
}
|
||||
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val context = LocalContext.current
|
||||
val handleLongPress: () -> Unit = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onMessageLongPress?.invoke(message)
|
||||
}
|
||||
|
||||
// The timestamp trails the body rather than occupying its own column, so a short message
|
||||
// no longer reserves a full-width row for eight grey characters.
|
||||
// no longer reserves a full-width row for eight grey characters. Self bubbles leave the
|
||||
// timestamp to their meta cluster (see BubbleTextMessageLayout).
|
||||
val bodyText = remember(
|
||||
displayMessage,
|
||||
currentUserNickname,
|
||||
@ -711,7 +776,9 @@ internal fun TextMessageLayout(
|
||||
colorScheme.onSurface,
|
||||
colorScheme.secondary,
|
||||
mentionPeerIdentities,
|
||||
timeFormatter
|
||||
timeFormatter,
|
||||
bubbles,
|
||||
isSelf
|
||||
) {
|
||||
formatTextMessageBody(
|
||||
message = displayMessage,
|
||||
@ -721,14 +788,23 @@ internal fun TextMessageLayout(
|
||||
linkColor = colorScheme.secondary,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
timeFormatter = timeFormatter,
|
||||
includeTimestamp = !bubbles || !isSelf,
|
||||
)
|
||||
}
|
||||
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val context = LocalContext.current
|
||||
val handleLongPress: () -> Unit = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onMessageLongPress?.invoke(message)
|
||||
|
||||
if (bubbles) {
|
||||
BubbleTextMessageLayout(
|
||||
message = message,
|
||||
senderText = senderText,
|
||||
bodyText = bodyText,
|
||||
isSelf = isSelf,
|
||||
showSender = showSender,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onLongPress = handleLongPress,
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
@ -788,6 +864,199 @@ internal fun TextMessageLayout(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classic messenger rendering of a text message: a rounded bubble that hugs its content, own
|
||||
* messages on the right and everyone else on the left, with the corner on the speaker's side
|
||||
* tightened into a subtle tail.
|
||||
*
|
||||
* The bubble is washed with the author's stable peer colour — the same identity-derived colour
|
||||
* the `@name` label and mention chips already use — so the speaker stays identifiable at a
|
||||
* glance without touching any surface, background, or theme colour. Body text keeps the
|
||||
* standard `onSurface` tone; only the bubble shell carries the identity.
|
||||
*/
|
||||
@Composable
|
||||
private fun BubbleTextMessageLayout(
|
||||
message: BitchatMessage,
|
||||
senderText: AnnotatedString,
|
||||
bodyText: AnnotatedString,
|
||||
isSelf: Boolean,
|
||||
showSender: Boolean,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onLongPress: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val context = LocalContext.current
|
||||
|
||||
val authorColor = remember(message, isSelf, palette) {
|
||||
if (isSelf) palette.accentOrange else colorForPeer(peerIdentityForMessage(message), palette)
|
||||
}
|
||||
|
||||
val corner = ChatVisualTokens.BubbleCornerRadius
|
||||
val tail = ChatVisualTokens.BubbleTailRadius
|
||||
val bubbleShape = if (isSelf) {
|
||||
RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = tail, bottomStart = corner)
|
||||
} else {
|
||||
RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = corner, bottomStart = tail)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = if (isSelf) Alignment.End else Alignment.Start,
|
||||
) {
|
||||
// Cap the bubble at a fraction of the row so long messages wrap instead of touching the
|
||||
// opposite edge, while short ones hug their content.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val maxBubbleWidth = maxWidth * ChatVisualTokens.BubbleMaxWidthFraction
|
||||
val density = LocalDensity.current
|
||||
val textCapPx = with(density) {
|
||||
(maxBubbleWidth - ChatVisualTokens.BubblePaddingHorizontal * 2 - 2.dp).toPx()
|
||||
}
|
||||
var bodyLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
var clusterSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
// The meta cluster (timestamp, then delivery checks for own private messages) rides
|
||||
// flush-right on the body's last line when that line has room for it, and drops
|
||||
// below the text only when it does not. Placement is computed from the laid-out
|
||||
// text, so wrapping is never influenced by the cluster: no early wraps, no slack
|
||||
// carved out of the first lines, no minimum bubble width.
|
||||
val metaGapPx = with(density) { 8.dp.toPx() }
|
||||
val metaPlan = remember(bodyLayout, clusterSize, textCapPx) {
|
||||
val layout = bodyLayout ?: return@remember null
|
||||
if (layout.lineCount == 0 || clusterSize.width <= 0) return@remember null
|
||||
val lastLineRight = layout.getLineRight(layout.lineCount - 1)
|
||||
if (lastLineRight + metaGapPx + clusterSize.width <= textCapPx) {
|
||||
BubbleMetaPlan(
|
||||
widthPx = maxOf(layout.size.width.toFloat(), lastLineRight + metaGapPx + clusterSize.width),
|
||||
reserveOwnLine = false,
|
||||
)
|
||||
} else {
|
||||
BubbleMetaPlan(
|
||||
widthPx = maxOf(layout.size.width.toFloat(), clusterSize.width.toFloat()),
|
||||
reserveOwnLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(if (isSelf) Alignment.CenterEnd else Alignment.CenterStart)
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = authorColor.copy(alpha = ChatVisualTokens.BubbleBorderAlpha),
|
||||
shape = bubbleShape
|
||||
)
|
||||
.background(
|
||||
color = authorColor.copy(alpha = ChatVisualTokens.BubbleBackgroundAlpha),
|
||||
shape = bubbleShape
|
||||
)
|
||||
.padding(
|
||||
horizontal = ChatVisualTokens.BubblePaddingHorizontal,
|
||||
vertical = ChatVisualTokens.BubblePaddingVertical,
|
||||
)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
// The sender's name heads the first bubble of their run, like classic group
|
||||
// messengers, instead of floating above it. Own bubbles never show a name —
|
||||
// the end side is attribution enough. Continuation bubbles skip it too.
|
||||
if (showSender && !isSelf) {
|
||||
AnnotatedClickableText(
|
||||
text = senderText,
|
||||
annotationTags = listOf("nickname_click"),
|
||||
onAnnotationClick = { tag, item ->
|
||||
if (tag == "nickname_click" && !isSelf && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(item)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
onLongPress = onLongPress,
|
||||
fontFamily = BitchatFontFamily,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MessageSenderTextStyle,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = if (isSelf && metaPlan != null) {
|
||||
Modifier.width(with(density) { metaPlan!!.widthPx.toDp() })
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
) {
|
||||
AnnotatedClickableText(
|
||||
text = bodyText,
|
||||
annotationTags = listOf("geohash_click", "url_click"),
|
||||
onAnnotationClick = { tag, item ->
|
||||
when (tag) {
|
||||
"geohash_click" -> {
|
||||
navigateToGeohash(context, item)
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
true
|
||||
}
|
||||
|
||||
"url_click" -> {
|
||||
openMessageUrl(context, item)
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
onLongPress = onLongPress,
|
||||
modifier = Modifier.padding(
|
||||
bottom = if (metaPlan?.reserveOwnLine == true) {
|
||||
with(density) { clusterSize.height.toDp() }
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
),
|
||||
fontFamily = BitchatFontFamily,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Visible,
|
||||
style = MessageBodyTextStyle.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
onTextLayout = { bodyLayout = it },
|
||||
)
|
||||
|
||||
if (isSelf) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.onSizeChanged { clusterSize = it }
|
||||
.graphicsLayer { alpha = if (metaPlan != null) 1f else 0f },
|
||||
) {
|
||||
Text(
|
||||
text = formatTextMessageMetadata(message, timeFormatter),
|
||||
fontFamily = BitchatFontFamily,
|
||||
)
|
||||
if (message.isPrivate) {
|
||||
message.deliveryStatus?.let { status ->
|
||||
Spacer(Modifier.width(4.dp))
|
||||
DeliveryStatusIcon(status = status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class BubbleMetaPlan(
|
||||
val widthPx: Float,
|
||||
val reserveOwnLine: Boolean,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun CashuMessageContent(
|
||||
@ -797,6 +1066,7 @@ private fun CashuMessageContent(
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
bubbles: Boolean = false,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
@ -816,6 +1086,7 @@ private fun CashuMessageContent(
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
bubbles = bubbles,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
bodyContent = remainingText,
|
||||
@ -931,43 +1202,77 @@ private fun redeemCashu(context: Context, token: String, preferWallet: Boolean)
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(web))) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-check target colours for the delivery marker.
|
||||
*
|
||||
* Both checks always render — grey (disabled) until an acknowledgement turns them on — so a
|
||||
* status change recolours in place and never reflows text around it. Read receipts use the
|
||||
* app's primary green rather than a separate accent. [status] == null yields the all-grey
|
||||
* baseline used while a message is still being sent.
|
||||
*/
|
||||
private fun deliveryCheckColors(status: DeliveryStatus?, colorScheme: ColorScheme): Pair<Color, Color> {
|
||||
val grey = colorScheme.onSurface.copy(alpha = 0.35f)
|
||||
val green = colorScheme.primary
|
||||
return when (status) {
|
||||
is DeliveryStatus.Read -> green to green
|
||||
is DeliveryStatus.Delivered -> green to grey
|
||||
is DeliveryStatus.PartiallyDelivered -> green to grey
|
||||
is DeliveryStatus.Failed -> colorScheme.error to colorScheme.error
|
||||
else -> grey to grey
|
||||
}
|
||||
}
|
||||
|
||||
/** Acknowledgement progress ordering, used to fire the pop only when the state advances. */
|
||||
private fun deliveryCheckRank(status: DeliveryStatus): Int = when (status) {
|
||||
is DeliveryStatus.Read -> 3
|
||||
is DeliveryStatus.Delivered -> 2
|
||||
is DeliveryStatus.PartiallyDelivered -> 2
|
||||
is DeliveryStatus.Failed -> 1
|
||||
else -> 0
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeliveryStatusIcon(status: DeliveryStatus) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val (firstTarget, secondTarget) = deliveryCheckColors(status, colorScheme)
|
||||
val first by animateColorAsState(
|
||||
targetValue = firstTarget,
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS),
|
||||
label = "firstCheckColor",
|
||||
)
|
||||
val second by animateColorAsState(
|
||||
targetValue = secondTarget,
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS),
|
||||
label = "secondCheckColor",
|
||||
)
|
||||
|
||||
// Status advances on its own as acks come back, so a hard glyph swap reads as a flicker.
|
||||
// Keyed on the status *type* rather than the instance, because Delivered/Read carry a
|
||||
// timestamp that would otherwise retrigger the transition on every identical update.
|
||||
AnimatedContent(
|
||||
targetState = status::class,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(BitchatMotion.STANDARD_MS)) togetherWith
|
||||
fadeOut(tween(BitchatMotion.QUICK_MS))
|
||||
},
|
||||
label = "deliveryStatus"
|
||||
) { statusClass ->
|
||||
val (text, color, weight) = when (statusClass) {
|
||||
DeliveryStatus.Sending::class ->
|
||||
Triple(R.string.status_sending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
|
||||
// Subtle hollow marker for Sent; a single check is reserved for Delivered (iOS parity).
|
||||
DeliveryStatus.Sent::class ->
|
||||
Triple(R.string.status_pending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
|
||||
DeliveryStatus.Delivered::class ->
|
||||
Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal)
|
||||
DeliveryStatus.Read::class ->
|
||||
Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold)
|
||||
DeliveryStatus.Failed::class ->
|
||||
Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal)
|
||||
// A single subdued check, without the numeric label.
|
||||
else ->
|
||||
Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
|
||||
// Snappy micro pop when the state advances to (more) acknowledged. Keyed on the rank, not
|
||||
// the instance, because Delivered/Read carry timestamps that would retrigger it otherwise.
|
||||
val scale = remember { Animatable(1f) }
|
||||
LaunchedEffect(deliveryCheckRank(status)) {
|
||||
if (deliveryCheckRank(status) >= 2) {
|
||||
scale.snapTo(1.3f)
|
||||
scale.animateTo(1f, spring(dampingRatio = 0.55f, stiffness = 900f))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(text),
|
||||
fontSize = 10.sp,
|
||||
color = color,
|
||||
fontWeight = weight
|
||||
)
|
||||
}
|
||||
|
||||
val text = remember(first, second) {
|
||||
androidx.compose.ui.text.buildAnnotatedString {
|
||||
pushStyle(androidx.compose.ui.text.SpanStyle(color = first))
|
||||
append("✓")
|
||||
pop()
|
||||
pushStyle(androidx.compose.ui.text.SpanStyle(color = second))
|
||||
append("✓")
|
||||
pop()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
scaleX = scale.value
|
||||
scaleY = scale.value
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.ui.isFromSelf
|
||||
import java.text.SimpleDateFormat
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@ -38,7 +39,8 @@ fun AudioMessageItem(
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true
|
||||
showSender: Boolean = true,
|
||||
bubbles: Boolean = false
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val context = LocalContext.current
|
||||
@ -46,6 +48,8 @@ fun AudioMessageItem(
|
||||
.getInstance(context).liveMessageIDs.collectAsState()
|
||||
val isLive = message.id in liveMessageIDs
|
||||
val path = message.content.trim()
|
||||
// Bubble mode aligns self-authored voice notes to the end side, mirroring text bubbles.
|
||||
val isSelfInBubbles = bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
// Derive sending progress if applicable
|
||||
val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) {
|
||||
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> {
|
||||
@ -55,7 +59,10 @@ fun AudioMessageItem(
|
||||
}
|
||||
else -> null to null
|
||||
}
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = if (isSelfInBubbles) Alignment.End else Alignment.Start,
|
||||
) {
|
||||
// Header: nickname + timestamp line above the audio note, identical styling to text messages
|
||||
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
@ -96,7 +103,10 @@ fun AudioMessageItem(
|
||||
VoiceNotePlayer(
|
||||
path = path,
|
||||
progressOverride = overrideProgress,
|
||||
progressColor = overrideColor
|
||||
progressColor = overrideColor,
|
||||
// Self voice notes hug the end side like other self content instead of
|
||||
// spanning the full row.
|
||||
modifier = if (isSelfInBubbles) Modifier.widthIn(max = 300.dp) else Modifier
|
||||
)
|
||||
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
|
||||
if (showCancel) {
|
||||
|
||||
@ -28,6 +28,7 @@ import com.bitchat.android.model.BitchatMessageType
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.ui.isFromSelf
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
@Composable
|
||||
@ -43,11 +44,23 @@ fun ImageMessageItem(
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true
|
||||
showSender: Boolean = true,
|
||||
bubbles: Boolean = false
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val path = message.content.trim()
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Bubble mode aligns self-authored media to the end side, mirroring text bubbles; the
|
||||
// corner on the speaker's side is tightened into the same subtle tail.
|
||||
val isSelfInBubbles = bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
val imageShape = when {
|
||||
!bubbles -> androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
|
||||
isSelfInBubbles -> androidx.compose.foundation.shape.RoundedCornerShape(10.dp, 10.dp, 3.dp, 10.dp)
|
||||
else -> androidx.compose.foundation.shape.RoundedCornerShape(10.dp, 10.dp, 10.dp, 3.dp)
|
||||
}
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = if (isSelfInBubbles) Alignment.End else Alignment.Start,
|
||||
) {
|
||||
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
@ -91,7 +104,10 @@ fun ImageMessageItem(
|
||||
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> if (st.total > 0) st.reached.toFloat() / st.total.toFloat() else 0f
|
||||
else -> null
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isSelfInBubbles) Arrangement.End else Arrangement.Start
|
||||
) {
|
||||
Box {
|
||||
if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) {
|
||||
// Cyberpunk block-reveal while sending
|
||||
@ -103,7 +119,7 @@ fun ImageMessageItem(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.aspectRatio(aspect)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp))
|
||||
.clip(imageShape)
|
||||
.clickable {
|
||||
val currentIndex = imagePaths.indexOf(path)
|
||||
onImageClick?.invoke(path, imagePaths, currentIndex)
|
||||
@ -117,7 +133,7 @@ fun ImageMessageItem(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.aspectRatio(aspect)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp))
|
||||
.clip(imageShape)
|
||||
.clickable {
|
||||
val currentIndex = imagePaths.indexOf(path)
|
||||
onImageClick?.invoke(path, imagePaths, currentIndex)
|
||||
|
||||
@ -23,6 +23,7 @@ import androidx.compose.ui.unit.sp
|
||||
@Composable
|
||||
fun VoiceNotePlayer(
|
||||
path: String,
|
||||
modifier: Modifier = Modifier,
|
||||
progressOverride: Float? = null,
|
||||
progressColor: Color? = null
|
||||
) {
|
||||
@ -86,7 +87,7 @@ fun VoiceNotePlayer(
|
||||
DisposableEffect(Unit) { onDispose { try { player.release() } catch (_: Exception) {} } }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
|
||||
49
app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt
Normal file
49
app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt
Normal file
@ -0,0 +1,49 @@
|
||||
package com.bitchat.android.ui.theme
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Chat transcript presentation.
|
||||
*
|
||||
* [Matrix] is the established terminal-style transcript: a flat, left-aligned monochrome
|
||||
* stream where colour is reserved for `@names` and links.
|
||||
*
|
||||
* [Bubbles] is the classic messenger layout: messages hug their content inside rounded
|
||||
* bubbles, own messages on the right and everyone else on the left. Each bubble is tinted
|
||||
* with its author's stable peer colour, so the speaker stays identifiable without reading
|
||||
* the name. Colours, surfaces, and typography are untouched — only the message layout
|
||||
* changes.
|
||||
*/
|
||||
enum class ChatUiMode {
|
||||
Matrix,
|
||||
Bubbles;
|
||||
|
||||
val isMatrix: Boolean get() = this == Matrix
|
||||
val isBubbles: Boolean get() = this == Bubbles
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple SharedPreferences-backed manager for the chat UI mode with a StateFlow.
|
||||
* Mirrors [ThemePreferenceManager].
|
||||
*/
|
||||
object ChatUiModeManager {
|
||||
private const val PREFS_NAME = "bitchat_settings"
|
||||
private const val KEY_CHAT_UI_MODE = "chat_ui_mode"
|
||||
|
||||
private val _modeFlow = MutableStateFlow(ChatUiMode.Bubbles)
|
||||
val modeFlow: StateFlow<ChatUiMode> = _modeFlow
|
||||
|
||||
fun init(context: Context) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val saved = prefs.getString(KEY_CHAT_UI_MODE, ChatUiMode.Bubbles.name)
|
||||
_modeFlow.value = runCatching { ChatUiMode.valueOf(saved!!) }.getOrDefault(ChatUiMode.Bubbles)
|
||||
}
|
||||
|
||||
fun set(context: Context, mode: ChatUiMode) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().putString(KEY_CHAT_UI_MODE, mode.name).apply()
|
||||
_modeFlow.value = mode
|
||||
}
|
||||
}
|
||||
@ -37,6 +37,30 @@ internal object ChatVisualTokens {
|
||||
val SenderTopPadding: Dp = 8.dp
|
||||
val SenderToBodySpacing: Dp = 4.dp
|
||||
|
||||
// MARK: - Bubble geometry (ChatUiMode.Bubbles)
|
||||
|
||||
/** Rounded corner on the three "free" corners of a message bubble. */
|
||||
val BubbleCornerRadius: Dp = 16.dp
|
||||
|
||||
/** Tightened corner on the speaker's own side, giving the bubble a subtle tail. */
|
||||
val BubbleTailRadius: Dp = 4.dp
|
||||
|
||||
/** Padding inside a bubble, around the text. */
|
||||
val BubblePaddingHorizontal: Dp = 12.dp
|
||||
val BubblePaddingVertical: Dp = 8.dp
|
||||
|
||||
/** A bubble never grows past this fraction of the list width, so long lines still wrap. */
|
||||
const val BubbleMaxWidthFraction: Float = 0.80f
|
||||
|
||||
/**
|
||||
* Author-colour wash inside a bubble. Matches the mention-chip treatment so a tinted
|
||||
* bubble stays legible on both the near-black and near-white chat surfaces.
|
||||
*/
|
||||
const val BubbleBackgroundAlpha: Float = 0.18f
|
||||
|
||||
/** Author-colour hairline around a bubble; stronger than the fill so the shape reads. */
|
||||
const val BubbleBorderAlpha: Float = 0.38f
|
||||
|
||||
const val SenderSuffixAlpha: Float = 0.60f
|
||||
const val HighlightAlpha: Float = 0.20f
|
||||
const val MutedTextAlpha: Float = 0.50f
|
||||
|
||||
@ -23,9 +23,6 @@ object AppConstants {
|
||||
const val CONNECTION_CLEANUP_INTERVAL_MS: Long = 30_000L
|
||||
const val BROADCAST_CLEANUP_DELAY_MS: Long = 500L
|
||||
|
||||
// GATT client RSSI updates
|
||||
const val RSSI_UPDATE_INTERVAL_MS: Long = 5_000L
|
||||
|
||||
object Gatt {
|
||||
val SERVICE_UUID: UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
|
||||
val CHARACTERISTIC_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
|
||||
|
||||
@ -293,12 +293,6 @@
|
||||
<string name="media_type_file">File</string>
|
||||
|
||||
<!-- Mga status ng mensahe -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Tulong na text sa abiso -->
|
||||
<string name="notification_sent_image">📷 nagpadala ng larawan</string>
|
||||
|
||||
@ -294,12 +294,6 @@
|
||||
<string name="media_type_file">Fichier</string>
|
||||
|
||||
<!-- Statuts de message -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Aide de texte de notification -->
|
||||
<string name="notification_sent_image">📷 a envoyé une image</string>
|
||||
|
||||
@ -349,12 +349,6 @@
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">תמונה</string>
|
||||
<string name="media_type_file">קובץ</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 שלח/ה תמונה</string>
|
||||
<string name="notification_sent_voice">🎤 שלח/ה הודעה קולית</string>
|
||||
<string name="notification_sent_file">📎 שלח/ה קובץ</string>
|
||||
|
||||
@ -338,12 +338,6 @@
|
||||
<string name="warning_emoji">⚠️</string>
|
||||
|
||||
<!-- Icone stato messaggio -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Helper file notifiche -->
|
||||
<string name="notification_file_pdf">📄</string>
|
||||
|
||||
@ -302,12 +302,6 @@
|
||||
<string name="media_type_file">Rakitra</string>
|
||||
|
||||
<!-- Famantarana toe-javatra hafatra -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Mpanampy lahatsoratra fampandrenesana -->
|
||||
<string name="notification_sent_image">📷 nandefasa sary</string>
|
||||
|
||||
@ -374,12 +374,6 @@
|
||||
<string name="media_type_file">Fail</string>
|
||||
|
||||
<!-- Message status icons -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Notification text helpers -->
|
||||
<string name="notification_sent_image">📷 menghantar imej</string>
|
||||
|
||||
@ -293,12 +293,6 @@
|
||||
<string name="media_type_file">फाइल</string>
|
||||
|
||||
<!-- सन्देश स्थिति -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- सूचना पाठ सहायक -->
|
||||
<string name="notification_sent_image">📷 तस्वीर पठाइयो</string>
|
||||
|
||||
@ -339,12 +339,6 @@
|
||||
<string name="debug_question_mark">?</string>
|
||||
|
||||
<!-- Berichtstatus-pictogrammen -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Meldingsbestand-emojis -->
|
||||
<string name="notification_file_pdf">📄</string>
|
||||
|
||||
@ -347,12 +347,6 @@
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">Obraz</string>
|
||||
<string name="media_type_file">Plik</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 wysłał(a) obraz</string>
|
||||
<string name="notification_sent_voice">🎤 wysłał(a) wiadomość głosową</string>
|
||||
<string name="notification_sent_file">📎 wysłał(a) plik</string>
|
||||
|
||||
@ -276,13 +276,6 @@
|
||||
<string name="media_type_image">Изображение</string>
|
||||
<string name="media_type_file">Файл</string>
|
||||
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<string name="notification_sent_image">📷 отправил изображение</string>
|
||||
<string name="notification_sent_voice">🎤 отправил голосовое</string>
|
||||
<string name="notification_sent_file">📎 отправил файл</string>
|
||||
|
||||
@ -276,13 +276,6 @@
|
||||
<string name="media_type_image">Bild</string>
|
||||
<string name="media_type_file">Fil</string>
|
||||
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<string name="notification_sent_image">📷 skickade en bild</string>
|
||||
<string name="notification_sent_voice">🎤 skickade ett röstmeddelande</string>
|
||||
<string name="notification_sent_file">📎 skickade en fil</string>
|
||||
|
||||
@ -352,12 +352,6 @@
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">படம்</string>
|
||||
<string name="media_type_file">கோப்பு</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 ஒரு படத்தை அனுப்பியது</string>
|
||||
<string name="notification_sent_voice">🎤 ஒரு குரல் செய்தியை அனுப்பியது</string>
|
||||
<string name="notification_sent_file">📎 ஒரு கோப்பை அனுப்பியது</string>
|
||||
|
||||
@ -276,13 +276,6 @@
|
||||
<string name="media_type_image">Görsel</string>
|
||||
<string name="media_type_file">Dosya</string>
|
||||
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<string name="notification_sent_image">📷 görsel gönderdi</string>
|
||||
<string name="notification_sent_voice">🎤 sesli mesaj gönderdi</string>
|
||||
<string name="notification_sent_file">📎 dosya gönderdi</string>
|
||||
|
||||
@ -341,12 +341,6 @@
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">Зображення</string>
|
||||
<string name="media_type_file">Файл</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 надіслав(-ла) зображення</string>
|
||||
<string name="notification_sent_voice">🎤 надіслав(-ла) голосове повідомлення</string>
|
||||
<string name="notification_sent_file">📎 надіслав(-ла) файл</string>
|
||||
|
||||
@ -347,12 +347,6 @@
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">图片</string>
|
||||
<string name="media_type_file">文件</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 发送了一张图片</string>
|
||||
<string name="notification_sent_voice">🎤 发送了一条语音消息</string>
|
||||
<string name="notification_sent_file">📎 发送了一个文件</string>
|
||||
|
||||
@ -347,12 +347,6 @@
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">圖片</string>
|
||||
<string name="media_type_file">檔案</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 傳送了一張圖片</string>
|
||||
<string name="notification_sent_voice">🎤 傳送了一則語音訊息</string>
|
||||
<string name="notification_sent_file">📎 傳送了一個檔案</string>
|
||||
|
||||
@ -293,12 +293,6 @@
|
||||
<string name="media_type_file">文件</string>
|
||||
|
||||
<!-- 消息状态 -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- 通知文本辅助 -->
|
||||
<string name="notification_sent_image">📷 发送了图片</string>
|
||||
|
||||
@ -196,6 +196,8 @@
|
||||
<string name="about_system">System</string>
|
||||
<string name="about_light">Light</string>
|
||||
<string name="about_dark">Dark</string>
|
||||
<string name="chat_ui_matrix">Matrix</string>
|
||||
<string name="chat_ui_bubbles">Bubbles</string>
|
||||
<string name="about_pow">Proof of Work</string>
|
||||
<string name="about_pow_off">PoW Off</string>
|
||||
<string name="about_pow_on">PoW On</string>
|
||||
@ -556,14 +558,6 @@
|
||||
<string name="media_type_image">Image</string>
|
||||
<string name="media_type_file">File</string>
|
||||
|
||||
<!-- Message status icons -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Notification text helpers -->
|
||||
<string name="notification_sent_image">📷 sent an image</string>
|
||||
<string name="notification_sent_voice">🎤 sent a voice message</string>
|
||||
|
||||
@ -385,7 +385,7 @@ class ChatUIUtilsTest {
|
||||
palette = palette,
|
||||
)
|
||||
|
||||
assertEquals("@carol#04af", sender.text)
|
||||
assertEquals("@carol #04af", sender.text)
|
||||
|
||||
val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" }
|
||||
val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" }
|
||||
|
||||
@ -8,6 +8,7 @@ import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertFalse
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
@ -161,4 +162,30 @@ class CommandProcessorTest() {
|
||||
)
|
||||
assertEquals(0, chatState.getMessagesValue().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearSuggestions hides the command suggestion popup`() {
|
||||
// Typing "/" opens the command popup.
|
||||
commandProcessor.updateCommandSuggestions("/")
|
||||
assertTrue(chatState.getShowCommandSuggestionsValue())
|
||||
|
||||
// The send handlers call this after clearing the field in code, which does
|
||||
// not run the text-change handler that normally hides the popup.
|
||||
commandProcessor.clearSuggestions()
|
||||
assertFalse(chatState.getShowCommandSuggestionsValue())
|
||||
assertTrue(chatState.getCommandSuggestionsValue().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearSuggestions hides the mention suggestion popup`() {
|
||||
whenever(meshService.getPeerNicknames()).thenReturn(mapOf("peer-1" to "alice"))
|
||||
|
||||
// Typing "@a" opens the mention popup.
|
||||
commandProcessor.updateMentionSuggestions("@a", meshService, viewModel = null)
|
||||
assertTrue(chatState.getShowMentionSuggestionsValue())
|
||||
|
||||
commandProcessor.clearSuggestions()
|
||||
assertFalse(chatState.getShowMentionSuggestionsValue())
|
||||
assertTrue(chatState.getMentionSuggestionsValue().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 919 KiB After Width: | Height: | Size: 919 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.3 MiB |
@ -1,10 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:viewportWidth="135.4667"
|
||||
android:viewportHeight="135.4667"
|
||||
android:width="108dp"
|
||||
android:height="108dp">
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M-0.2617076 -0.1145289H135.7284V135.5812H-0.2617076V-0.1145289Z"
|
||||
android:fillColor="#000000" />
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
|
||||
@ -1,15 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:viewportWidth="135.4667"
|
||||
android:viewportHeight="135.4667"
|
||||
android:width="108dp"
|
||||
android:height="108dp">
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!--
|
||||
BitChatIcon glyph (About button) in app primary green (#32D74B),
|
||||
with pixel "BIT" in black. Sized to keep clear padding inside the circle.
|
||||
-->
|
||||
<group
|
||||
android:scaleX="0.2645833"
|
||||
android:scaleY="0.2645833"
|
||||
android:translateX="-1.0877"
|
||||
android:translateY="47.11238">
|
||||
android:translateX="26.8"
|
||||
android:translateY="27"
|
||||
android:scaleX="6.8"
|
||||
android:scaleY="6.8">
|
||||
<path
|
||||
android:pathData="M187.45493 73.477844q9.17966 2.148433 14.38798 8.333313 5.20832 6.18488 5.20832 14.713504 0 12.499969 -9.11456 19.921819 -9.04945 7.42186 -21.80984 7.42186h-26.1067V32.006596h25.45566q13.28122 0 20.76818 5.85936 7.48696 5.794256 7.48696 16.406208 0 6.5104 -4.36197 11.588512 -4.29686 5.078112 -11.91403 7.356752zM160.69718 68.52994h12.49997q19.59631 0 19.59631 -13.99736 0 -6.380192 -4.8177 -9.7656 -4.8177 -3.450512 -14.12757 -3.450512h-13.15101zm0 46.02853h12.10935q10.61195 0 16.79683 -4.8177 6.24998 -4.81769 6.24998 -13.216109 0 -8.919248 -6.11977 -13.802048 -6.11978 -4.8828 -17.70829 -4.8828h-11.3281zm116.14554 9.30987h-41.73166v-9.30987h15.82027V41.316468h-15.82027v-9.309872h41.73166v9.309872h-15.23433v73.242002h15.23433zM370.20186 40.665428H341.88162V123.86834H331.26967V40.665428h-28.45045v-8.658832h67.38264z"
|
||||
android:fillColor="#FFFFFF" />
|
||||
android:fillColor="#FF32D74B"
|
||||
android:pathData="M2,0L6,0L6,1L7,1L7,2L8,2L8,5L7,5L7,6L6,6L6,8L5,8L5,7L3,7L3,6L1,6L1,5L0,5L0,2L1,2L1,1L2,1Z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M1.8,1.95h0.4v0.4h-0.4zM2.2,1.95h0.4v0.4h-0.4zM1.8,2.35h0.4v0.4h-0.4zM2.6,2.35h0.4v0.4h-0.4zM1.8,2.75h0.4v0.4h-0.4zM2.2,2.75h0.4v0.4h-0.4zM1.8,3.15h0.4v0.4h-0.4zM2.6,3.15h0.4v0.4h-0.4zM1.8,3.55h0.4v0.4h-0.4zM2.2,3.55h0.4v0.4h-0.4zM3.4,1.95h0.4v0.4h-0.4zM3.8,1.95h0.4v0.4h-0.4zM4.2,1.95h0.4v0.4h-0.4zM3.8,2.35h0.4v0.4h-0.4zM3.8,2.75h0.4v0.4h-0.4zM3.8,3.15h0.4v0.4h-0.4zM3.4,3.55h0.4v0.4h-0.4zM3.8,3.55h0.4v0.4h-0.4zM4.2,3.55h0.4v0.4h-0.4zM5,1.95h0.4v0.4h-0.4zM5.4,1.95h0.4v0.4h-0.4zM5.8,1.95h0.4v0.4h-0.4zM5.4,2.35h0.4v0.4h-0.4zM5.4,2.75h0.4v0.4h-0.4zM5.4,3.15h0.4v0.4h-0.4zM5.4,3.55h0.4v0.4h-0.4z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:name="vector"
|
||||
android:viewportWidth="135.4667"
|
||||
android:viewportHeight="135.4667"
|
||||
android:width="108dp"
|
||||
android:height="108dp">
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group
|
||||
android:scaleX="0.2645833"
|
||||
android:scaleY="0.2645833"
|
||||
android:translateX="-1.0877"
|
||||
android:translateY="47.11238">
|
||||
android:translateX="26.8"
|
||||
android:translateY="27"
|
||||
android:scaleX="6.8"
|
||||
android:scaleY="6.8">
|
||||
<path
|
||||
android:pathData="M187.45493 73.477844q9.17966 2.148433 14.38798 8.333313 5.20832 6.18488 5.20832 14.713504 0 12.499969 -9.11456 19.921819 -9.04945 7.42186 -21.80984 7.42186h-26.1067V32.006596h25.45566q13.28122 0 20.76818 5.85936 7.48696 5.794256 7.48696 16.406208 0 6.5104 -4.36197 11.588512 -4.29686 5.078112 -11.91403 7.356752zM160.69718 68.52994h12.49997q19.59631 0 19.59631 -13.99736 0 -6.380192 -4.8177 -9.7656 -4.8177 -3.450512 -14.12757 -3.450512h-13.15101zm0 46.02853h12.10935q10.61195 0 16.79683 -4.8177 6.24998 -4.81769 6.24998 -13.216109 0 -8.919248 -6.11977 -13.802048 -6.11978 -4.8828 -17.70829 -4.8828h-11.3281zm116.14554 9.30987h-41.73166v-9.30987h15.82027V41.316468h-15.82027v-9.309872h41.73166v9.309872h-15.23433v73.242002h15.23433zM370.20186 40.665428H341.88162V123.86834H331.26967V40.665428h-28.45045v-8.658832h67.38264z"
|
||||
android:fillColor="#000000" />
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M2,0L6,0L6,1L7,1L7,2L8,2L8,5L7,5L7,6L6,6L6,8L5,8L5,7L3,7L3,6L1,6L1,5L0,5L0,2L1,2L1,1L2,1ZM1.8,1.95h0.4v0.4h-0.4zM2.2,1.95h0.4v0.4h-0.4zM1.8,2.35h0.4v0.4h-0.4zM2.6,2.35h0.4v0.4h-0.4zM1.8,2.75h0.4v0.4h-0.4zM2.2,2.75h0.4v0.4h-0.4zM1.8,3.15h0.4v0.4h-0.4zM2.6,3.15h0.4v0.4h-0.4zM1.8,3.55h0.4v0.4h-0.4zM2.2,3.55h0.4v0.4h-0.4zM3.4,1.95h0.4v0.4h-0.4zM3.8,1.95h0.4v0.4h-0.4zM4.2,1.95h0.4v0.4h-0.4zM3.8,2.35h0.4v0.4h-0.4zM3.8,2.75h0.4v0.4h-0.4zM3.8,3.15h0.4v0.4h-0.4zM3.4,3.55h0.4v0.4h-0.4zM3.8,3.55h0.4v0.4h-0.4zM4.2,3.55h0.4v0.4h-0.4zM5,1.95h0.4v0.4h-0.4zM5.4,1.95h0.4v0.4h-0.4zM5.8,1.95h0.4v0.4h-0.4zM5.4,2.35h0.4v0.4h-0.4zM5.4,2.75h0.4v0.4h-0.4zM5.4,3.15h0.4v0.4h-0.4zM5.4,3.55h0.4v0.4h-0.4z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user