mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
watch works
This commit is contained in:
parent
bd3bb8c774
commit
71c257669a
@ -11,7 +11,7 @@
|
||||
| M2 | BLE transport & background service on watch | done |
|
||||
| M3 | Global chat | done |
|
||||
| M4 | Noise DMs & people screen | done |
|
||||
| M5 | File/image receive & display — **DEFERRED** (post-M7, later day) | deferred |
|
||||
| M5 | Files/images receive + voice notes (push-to-talk) + input redesign | in-progress |
|
||||
| M6 | ADB test hook & mesh_lab interop | done |
|
||||
| M7 | Polish & final design pass | done |
|
||||
|
||||
@ -226,22 +226,51 @@ auto-initiates the handshake. Session recovery after watch force-stop verified b
|
||||
|
||||
---
|
||||
|
||||
### M5 — File/image receive & display — **DEFERRED**
|
||||
### M5 — Files/images receive + voice notes (push-to-talk) + input redesign
|
||||
|
||||
> Deferred to a later day (after M7). Milestones M6 and M7 do not depend on M5 and proceed
|
||||
> without it. The mesh_lab `file` scenario for the watch is skipped until M5 is un-deferred.
|
||||
> Revised scope (was: receive-only, deferred). Now includes voice messages as a first-class
|
||||
> input method and a native-Wear bottom-action redesign of the composer.
|
||||
|
||||
- [ ] Receive broadcast files (`MessageType.FILE_TRANSFER`, `BitchatFilePacket` TLV decode,
|
||||
fragment reassembly — all shared)
|
||||
- [ ] Receive Noise-encrypted private files (`NoisePayloadType.FILE_TRANSFER`)
|
||||
- [ ] Inline image rendering in chat timelines; full-screen image viewer (pinch/crown zoom);
|
||||
non-image files saved with a way to open/share them
|
||||
- [ ] Transfer progress indicator; respect shared fragment/size caps
|
||||
- [ ] Design check: screencaps of inline image, full-screen viewer, transfer progress
|
||||
**Files & images (receive + display)**
|
||||
|
||||
**Success criteria**: phone→watch image renders inline in both global chat and DM; SHA-256 of
|
||||
received file matches sender; screencap set approved. (Sending files from the watch is out of
|
||||
scope.)
|
||||
- [ ] Receive broadcast + Noise-encrypted private files (shared `BitchatFilePacket` TLV,
|
||||
`FileUtils.saveIncomingFile`, `messageTypeForMime` — already wired via shared `MessageHandler`)
|
||||
- [ ] Image messages render as compact inline thumbnails (rounded, fit-width); tap → full-screen
|
||||
viewer (black surface, fit-to-screen, dismiss) — mirrors the phone's `ImageMessageItem` /
|
||||
`FullScreenImageViewer`
|
||||
- [ ] Non-media files: compact chip (name + size)
|
||||
- [ ] mesh_lab: add `file_recv` to the wear test hook; enable `file` + `file_private` scenarios
|
||||
for the watch
|
||||
|
||||
**Voice notes (first-class)**
|
||||
|
||||
- [ ] RECORD_AUDIO permission (manifest + just-in-time runtime request)
|
||||
- [ ] Push-to-talk recording: press-and-hold starts recording, release sends (phone UX constants:
|
||||
220 ms hold threshold, 10 s cap, ~80 ms amplitude polls); full-screen overlay that fades in
|
||||
with a live waveform animation + elapsed time; shared `VoiceRecorder` (16 kHz mono AAC,
|
||||
`audio/mp4`, `.m4a`)
|
||||
- [ ] Send as `BitchatFilePacket` broadcast in global chat (`MeshCore.sendFileBroadcast`); in a
|
||||
DM thread send Noise-encrypted (`MeshCore.sendFilePrivate` with handshake/prep retry)
|
||||
- [ ] Received voice notes (`BitchatMessageType.Audio`, `content` = local path) render as a
|
||||
voice-note bubble: play/pause + waveform (shared `Waveform.kt` extractor, 120 bins) +
|
||||
duration; `MediaPlayer` playback
|
||||
|
||||
**Input redesign (native Wear bottom actions)**
|
||||
|
||||
- [ ] Replace the inline composer text field with two bottom action buttons docked via the
|
||||
native Wear pattern (`ScreenScaffold` `edgeButton` slot + `ButtonGroup` — adapts to round and
|
||||
square screens):
|
||||
- keyboard button → full-screen text input screen (field auto-focused, IME opens; the Pixel
|
||||
Watch Gboard provides built-in dictation there too)
|
||||
- mic button → push-to-talk (press-and-hold record, release send) with the full-screen
|
||||
waveform overlay
|
||||
- [ ] Remove the old inline `ChatComposer` row from chat/DM screens
|
||||
|
||||
**Success criteria**: phone→watch image renders inline and full-screen; `mesh_lab.py scenario
|
||||
file` and `file_private` (phone→watch) green with SHA-256 match; push-to-talk voice note
|
||||
recorded on the watch arrives on the phone and plays (phone log/evidence), and a phone-sent
|
||||
voice note renders + plays on the watch; screencap design review of all new screens/overlays on
|
||||
the round display.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ appcompat = "1.7.1"
|
||||
|
||||
# Compose
|
||||
compose-bom = "2026.06.01"
|
||||
compose-icons-extended = "1.7.8"
|
||||
|
||||
# Navigation
|
||||
navigation-compose = "2.9.8"
|
||||
@ -88,7 +89,7 @@ androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
|
||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
||||
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose-icons-extended" }
|
||||
|
||||
# Lifecycle
|
||||
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" }
|
||||
|
||||
@ -64,6 +64,7 @@ WATCH_PERMISSIONS = [
|
||||
"android.permission.BLUETOOTH_CONNECT",
|
||||
"android.permission.BLUETOOTH_ADVERTISE",
|
||||
"android.permission.POST_NOTIFICATIONS",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
]
|
||||
|
||||
|
||||
@ -230,7 +231,9 @@ class Device:
|
||||
if isinstance(value, bool):
|
||||
args += ["--ez", key, "true" if value else "false"]
|
||||
elif isinstance(value, int):
|
||||
args += ["--el", key, str(value)]
|
||||
# `am` stores --el as Long and --ei as Integer; on-device
|
||||
# readers use getIntExtra, so int extras must go via --ei.
|
||||
args += ["--ei", key, str(value)]
|
||||
else:
|
||||
args += ["--es", key, str(value)]
|
||||
try:
|
||||
@ -654,8 +657,9 @@ SCENARIOS = {
|
||||
"identity_reset": scenario_identity_reset,
|
||||
}
|
||||
|
||||
# Scenarios supported when device B is a watch (file transfer deferred on the watch).
|
||||
WATCH_SCENARIOS = ["dm", "broadcast", "raw", "session_recovery", "identity_reset"]
|
||||
# Scenarios supported when device B is a watch (file scenarios are receive-only: phone sends,
|
||||
# the watch must receive with matching digests).
|
||||
WATCH_SCENARIOS = ["dm", "broadcast", "raw", "file", "file_private", "session_recovery", "identity_reset"]
|
||||
|
||||
|
||||
def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
|
||||
@ -715,7 +719,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
raw = commands.add_parser("cmd", help="send a raw test-hook command to one device")
|
||||
raw.add_argument("--serial", required=True)
|
||||
raw.add_argument("cmd")
|
||||
raw.add_argument("--extra", action="append", default=[], help="key=value extra (repeatable)")
|
||||
raw.add_argument("--extra", action="append", default=[], help="key=value string extra (repeatable)")
|
||||
raw.add_argument("--extra-int", action="append", default=[], help="key=value int extra (repeatable)")
|
||||
raw.add_argument("--timeout-ms", type=int, default=60_000)
|
||||
return parser
|
||||
|
||||
@ -747,10 +752,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(json.dumps(evidence, indent=2, default=str))
|
||||
return 0 if evidence["status"] == "pass" else 1
|
||||
elif args.command == "cmd":
|
||||
extras: dict[str, object] = {}
|
||||
extras: dict[str, object] = {}
|
||||
for item in args.extra:
|
||||
key, _, value = item.partition("=")
|
||||
extras[key] = int(value) if value.isdigit() else value
|
||||
extras[key] = value
|
||||
for item in args.extra_int:
|
||||
key, _, value = item.partition("=")
|
||||
extras[key] = int(value)
|
||||
result = Device(args.serial, "device").cmd(args.cmd, timeout_ms=args.timeout_ms, **extras)
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
return 0 if result.get("status") == "ok" else 1
|
||||
|
||||
@ -80,6 +80,7 @@ val sharedSourceIncludes = listOf(
|
||||
"com/bitchat/android/nostr/Bech32.kt",
|
||||
"com/bitchat/android/nostr/GeohashAliasRegistry.kt",
|
||||
"com/bitchat/android/features/file/FileUtils.kt",
|
||||
"com/bitchat/android/features/voice/**",
|
||||
"com/bitchat/android/ui/debug/DebugSettingsManager.kt",
|
||||
"com/bitchat/android/ui/debug/DebugPreferenceManager.kt",
|
||||
"com/bitchat/android/util/AppConstants.kt",
|
||||
|
||||
@ -50,6 +50,7 @@ object WearTestHookDriver {
|
||||
"dm_recv" -> dmRecv(context, intent)
|
||||
"msg_recv" -> msgRecv(context, intent)
|
||||
"raw_send" -> rawSend(context, intent)
|
||||
"file_recv" -> fileRecv(context, intent)
|
||||
"state" -> state(context)
|
||||
"clear_results" -> clearResults(context)
|
||||
else -> err(cmd, "unknown command: $cmd")
|
||||
@ -280,6 +281,43 @@ object WearTestHookDriver {
|
||||
.put("peer", peerID)
|
||||
}
|
||||
|
||||
// MARK: - File transfer (receive only; the watch does not send files via test hook)
|
||||
|
||||
private suspend fun fileRecv(context: Context, intent: Intent): JSONObject {
|
||||
val timeoutMs = intent.getLongExtra("timeout_ms", 180_000L)
|
||||
val nameContains = intent.getStringExtra("name_contains")
|
||||
val startTime = System.currentTimeMillis()
|
||||
val dirs = listOf(
|
||||
File(context.cacheDir, "files/incoming"),
|
||||
File(context.cacheDir, "images/incoming")
|
||||
)
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val candidate = dirs
|
||||
.flatMap { it.listFiles()?.toList() ?: emptyList() }
|
||||
.filter { it.lastModified() >= startTime - 5_000 }
|
||||
.filter { nameContains == null || it.name.contains(nameContains) }
|
||||
.maxByOrNull { it.lastModified() }
|
||||
if (candidate != null) {
|
||||
val size1 = candidate.length()
|
||||
delay(500)
|
||||
if (candidate.length() == size1 && size1 > 0) {
|
||||
return ok("file_recv")
|
||||
.put("path", candidate.absolutePath)
|
||||
.put("name", candidate.name)
|
||||
.put("bytes", size1)
|
||||
.put(
|
||||
"sha256",
|
||||
java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(candidate.readBytes()).toHex()
|
||||
)
|
||||
}
|
||||
}
|
||||
delay(250)
|
||||
}
|
||||
return err("file_recv", "timeout after ${timeoutMs}ms")
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application
|
||||
|
||||
@ -44,12 +44,15 @@ import com.bitchat.watch.ui.DmScreen
|
||||
import com.bitchat.watch.ui.NicknameSetupScreen
|
||||
import com.bitchat.watch.ui.PeopleScreen
|
||||
import com.bitchat.watch.ui.WearChatState
|
||||
import com.bitchat.watch.ui.sendPrivateMessage
|
||||
import com.bitchat.watch.ui.sendPublicMessage
|
||||
import com.bitchat.watch.ui.theme.BitchatWearTheme
|
||||
|
||||
sealed interface WearScreen {
|
||||
data object Chat : WearScreen
|
||||
data object People : WearScreen
|
||||
data class Dm(val peerID: String) : WearScreen
|
||||
data class TextInput(val peerID: String?) : WearScreen
|
||||
}
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@ -149,9 +152,31 @@ fun WearNavHost() {
|
||||
label = "screenTransition"
|
||||
) { current ->
|
||||
when (current) {
|
||||
is WearScreen.Chat -> ChatScreen(onOpenPeople = { navigate(WearScreen.People) })
|
||||
is WearScreen.Chat -> ChatScreen(
|
||||
onOpenPeople = { navigate(WearScreen.People) },
|
||||
onOpenTextInput = { navigate(WearScreen.TextInput(null)) }
|
||||
)
|
||||
is WearScreen.People -> PeopleScreen(onOpenDm = { navigate(WearScreen.Dm(it)) })
|
||||
is WearScreen.Dm -> DmScreen(peerID = current.peerID)
|
||||
is WearScreen.Dm -> DmScreen(
|
||||
peerID = current.peerID,
|
||||
onOpenTextInput = { navigate(WearScreen.TextInput(current.peerID)) }
|
||||
)
|
||||
is WearScreen.TextInput -> {
|
||||
val mesh = WearMeshService.peek()
|
||||
com.bitchat.watch.ui.TextInputScreen(
|
||||
onSend = { text ->
|
||||
mesh?.let { m ->
|
||||
if (current.peerID == null) {
|
||||
sendPublicMessage(m, text)
|
||||
} else {
|
||||
val nick = m.getPeerNickname(current.peerID) ?: current.peerID
|
||||
sendPrivateMessage(m, current.peerID, nick, text)
|
||||
}
|
||||
}
|
||||
goBack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,6 +95,7 @@ class WearMeshService private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
routed.peerID?.let { pid ->
|
||||
maybeAutoHandshake(pid)
|
||||
try {
|
||||
meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000)
|
||||
} catch (_: Exception) { }
|
||||
@ -201,6 +202,31 @@ class WearMeshService private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proactively establish a Noise session with peers we have no session for (throttled to
|
||||
* one attempt per peer per 60 s). Peers may hold a stale session after we restart — the
|
||||
* protocol has no decrypt-failure kick path, so our fresh handshake replaces it and
|
||||
* restores encrypted DM/file delivery.
|
||||
*/
|
||||
private val handshakeAttempts = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||
|
||||
private fun maybeAutoHandshake(peerID: String) {
|
||||
if (peerID == myPeerID || hasEstablishedSession(peerID)) return
|
||||
val now = System.currentTimeMillis()
|
||||
val last = handshakeAttempts[peerID] ?: 0L
|
||||
if (now - last < 60_000) return
|
||||
handshakeAttempts[peerID] = now
|
||||
serviceScope.launch {
|
||||
delay(1_500)
|
||||
if (!hasEstablishedSession(peerID)) {
|
||||
try {
|
||||
Log.d(TAG, "Auto-initiating Noise handshake with ${peerID.take(8)}")
|
||||
initiateNoiseHandshake(peerID)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessageReceived(message: com.bitchat.android.model.BitchatMessage) {
|
||||
try {
|
||||
when {
|
||||
@ -298,6 +324,49 @@ class WearMeshService private constructor(private val context: Context) {
|
||||
return connectionManager.connectToAddress(address)
|
||||
}
|
||||
|
||||
fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
|
||||
meshCore.sendFileBroadcast(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Noise-encrypted private file transfer with session/prep retry (mirrors the phone's
|
||||
* dispatchFileSend): ensures an established session, then retries transient
|
||||
* preparation states (AwaitingPeerState/NeedsHandshake) before giving up.
|
||||
*/
|
||||
fun sendFilePrivateEncrypted(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) {
|
||||
serviceScope.launch {
|
||||
val sessionDeadline = System.currentTimeMillis() + 15_000
|
||||
while (!hasEstablishedSession(recipientPeerID) && System.currentTimeMillis() < sessionDeadline) {
|
||||
try { initiateNoiseHandshake(recipientPeerID) } catch (_: Exception) { }
|
||||
delay(500)
|
||||
}
|
||||
val transferId = com.bitchat.android.mesh.MeshPacketUtils.sha256Hex(
|
||||
file.encode() ?: return@launch
|
||||
)
|
||||
val prepDeadline = System.currentTimeMillis() + 30_000
|
||||
while (System.currentTimeMillis() < prepDeadline) {
|
||||
when (val prep = meshCore.prepareFilePrivate(recipientPeerID, file, transferId, allowLegacyFallback = false)) {
|
||||
is com.bitchat.android.mesh.PrivateMediaPreparation.Ready -> {
|
||||
prep.transfer.commit()
|
||||
return@launch
|
||||
}
|
||||
com.bitchat.android.mesh.PrivateMediaPreparation.AwaitingPeerState,
|
||||
com.bitchat.android.mesh.PrivateMediaPreparation.NeedsHandshake -> {
|
||||
if (prep == com.bitchat.android.mesh.PrivateMediaPreparation.NeedsHandshake) {
|
||||
try { initiateNoiseHandshake(recipientPeerID) } catch (_: Exception) { }
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "private voice note preparation failed: $prep")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "private voice note preparation timed out")
|
||||
}
|
||||
}
|
||||
|
||||
fun getPeerFingerprint(peerID: String): String? = meshCore.getPeerFingerprint(peerID)
|
||||
|
||||
fun getPeerNicknames(): Map<String, String> = meshCore.getPeerNicknames()
|
||||
|
||||
178
wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt
Normal file
178
wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt
Normal file
@ -0,0 +1,178 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.wear.compose.material3.Icon
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.Text
|
||||
import com.bitchat.watch.ui.media.WaveformBars
|
||||
import com.bitchat.watch.ui.theme.BitchatMotion
|
||||
import com.bitchat.watch.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||
|
||||
/**
|
||||
* Native Wear bottom action bar (designed for the ScreenScaffold `edgeButton` slot): a keyboard
|
||||
* button that opens the text input screen, and a push-to-talk mic button — press and hold to
|
||||
* record, release to send. Recording state lives in [VoiceNoteController], hoisted at screen
|
||||
* level so [VoiceRecordOverlay] can render full-screen outside this slot.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> if (granted) voice.start() }
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(38.dp)
|
||||
.clip(CircleShape)
|
||||
.background(palette.inputButton)
|
||||
.clickable { onKeyboard() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Keyboard,
|
||||
contentDescription = "type message",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(38.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (voice.recording) MaterialTheme.colorScheme.primary else palette.inputButton
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.RECORD_AUDIO
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) {
|
||||
voice.start()
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
tryAwaitRelease()
|
||||
voice.stop(send = true)
|
||||
}
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Mic,
|
||||
contentDescription = "push to talk",
|
||||
tint = if (voice.recording) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen push-to-talk overlay: fades in over the chat with a live waveform, elapsed time,
|
||||
* and a release hint. Rendered as a sibling of the screen content (NOT inside the edgeButton
|
||||
* slot, which would clip it to the slot bounds).
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceRecordOverlay(voice: VoiceNoteController) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
AnimatedVisibility(
|
||||
visible = voice.recording,
|
||||
enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)),
|
||||
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f))
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Mic,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
WaveformBars(
|
||||
samples = voice.liveSamples,
|
||||
progress = 1f,
|
||||
activeColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
)
|
||||
Text(
|
||||
text = "%d:%02d".format(
|
||||
voice.elapsedMs / 1000 / 60,
|
||||
voice.elapsedMs / 1000 % 60
|
||||
) + " / 0:10",
|
||||
style = ChatVisualTokens.SenderStyle,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
)
|
||||
Text(
|
||||
text = "release to send",
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color = palette.textTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,29 +1,13 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.speech.RecognizerIntent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@ -34,45 +18,48 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.foundation.lazy.items
|
||||
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
|
||||
import androidx.wear.compose.material3.Icon
|
||||
import androidx.wear.compose.material3.IconButton
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.ScreenScaffold
|
||||
import androidx.wear.compose.material3.Text
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import com.bitchat.watch.mesh.WearMeshService
|
||||
import com.bitchat.watch.ui.media.FileMessageChip
|
||||
import com.bitchat.watch.ui.media.FullScreenImageViewer
|
||||
import com.bitchat.watch.ui.media.ImageMessageItem
|
||||
import com.bitchat.watch.ui.media.VoiceNoteItem
|
||||
import com.bitchat.watch.ui.theme.BitchatMotion
|
||||
import com.bitchat.watch.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.watch.ui.theme.colorForPeer
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(onOpenPeople: () -> Unit) {
|
||||
fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
|
||||
val messages by AppStateStore.publicMessages.collectAsState()
|
||||
val peers by AppStateStore.peers.collectAsState()
|
||||
val unreadDms by WearChatState.unreadDms.collectAsState()
|
||||
val mesh = WearMeshService.peek()
|
||||
val myPeerID = mesh?.myPeerID ?: ""
|
||||
val listState = rememberScalingLazyListState()
|
||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val palette = LocalBitchatPalette.current
|
||||
val haptics = LocalHapticFeedback.current
|
||||
var viewerPath by remember { mutableStateOf<String?>(null) }
|
||||
val voice = rememberVoiceNoteController { path ->
|
||||
mesh?.let { sendVoiceNote(it, null, path) }
|
||||
}
|
||||
|
||||
var previousCount by remember { mutableStateOf(messages.size) }
|
||||
LaunchedEffect(messages.size) {
|
||||
@ -81,28 +68,35 @@ fun ChatScreen(onOpenPeople: () -> Unit) {
|
||||
if (last != null && last.senderPeerID != myPeerID) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
// Keep the newest message visible (header is index 0, messages follow)
|
||||
// Keep the newest message visible (index 0 in reverse layout)
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(messages.size)
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
previousCount = messages.size
|
||||
}
|
||||
|
||||
ScreenScaffold(scrollState = listState) {
|
||||
// Composer pinned outside the ScalingLazyColumn: edge items in a scaling list are
|
||||
// shrunk/faded and hard to tap reliably on a round screen.
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScalingLazyColumn(
|
||||
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScreenScaffold(scrollState = listState) {
|
||||
// Bottom padding keeps the last message just above the action bar, so messages
|
||||
// scroll all the way down to the buttons.
|
||||
// LazyColumn + reverseLayout: newest message anchors at the bottom above the action
|
||||
// bar; empty space collects at the top (ScalingLazyColumn center-anchors short
|
||||
// content, which left an awkward gap above the buttons).
|
||||
androidx.compose.foundation.lazy.LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 64.dp)
|
||||
reverseLayout = true,
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
top = 40.dp,
|
||||
bottom = 56.dp
|
||||
)
|
||||
) {
|
||||
item {
|
||||
ChatHeader(
|
||||
peerCount = peers.size,
|
||||
unreadDms = unreadDms.values.sum(),
|
||||
onOpenPeople = onOpenPeople
|
||||
items(messages.asReversed(), key = { it.id }) { message ->
|
||||
MessageItem(
|
||||
message = message,
|
||||
myPeerID = myPeerID,
|
||||
onOpenImage = { viewerPath = it }
|
||||
)
|
||||
}
|
||||
if (messages.isEmpty()) {
|
||||
@ -118,17 +112,31 @@ fun ChatScreen(onOpenPeople: () -> Unit) {
|
||||
)
|
||||
}
|
||||
}
|
||||
items(messages, key = { it.id }) { message ->
|
||||
MessageItem(message = message, myPeerID = myPeerID)
|
||||
item {
|
||||
ChatHeader(
|
||||
peerCount = peers.size,
|
||||
unreadDms = unreadDms.values.sum(),
|
||||
onOpenPeople = onOpenPeople
|
||||
)
|
||||
}
|
||||
}
|
||||
ChatComposer(
|
||||
onSend = { text ->
|
||||
mesh?.let { sendPublicMessage(it, text) }
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
|
||||
// Always-visible action bar (the framework's edgeButton slot auto-hides on scroll,
|
||||
// which would make push-to-talk unreachable mid-conversation).
|
||||
ChatActionBar(
|
||||
onKeyboard = onOpenTextInput,
|
||||
voice = voice,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 10.dp)
|
||||
)
|
||||
|
||||
VoiceRecordOverlay(voice)
|
||||
}
|
||||
|
||||
viewerPath?.let { path ->
|
||||
FullScreenImageViewer(path = path, onClose = { viewerPath = null })
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,7 +173,11 @@ private fun ChatHeader(peerCount: Int, unreadDms: Int, onOpenPeople: () -> Unit)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageItem(message: BitchatMessage, myPeerID: String) {
|
||||
fun MessageItem(
|
||||
message: BitchatMessage,
|
||||
myPeerID: String,
|
||||
onOpenImage: (String) -> Unit = {}
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val isSelf = message.senderPeerID == myPeerID
|
||||
val senderColor = when {
|
||||
@ -178,16 +190,12 @@ fun MessageItem(message: BitchatMessage, myPeerID: String) {
|
||||
LaunchedEffect(message.id) { appeared = true }
|
||||
val alpha by androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (appeared) 1f else 0f,
|
||||
animationSpec = androidx.compose.animation.core.tween(
|
||||
com.bitchat.watch.ui.theme.BitchatMotion.EMPHASIZED_MS
|
||||
),
|
||||
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.EMPHASIZED_MS),
|
||||
label = "msgAlpha"
|
||||
)
|
||||
val offset by androidx.compose.animation.core.animateDpAsState(
|
||||
targetValue = if (appeared) 0.dp else 6.dp,
|
||||
animationSpec = androidx.compose.animation.core.tween(
|
||||
com.bitchat.watch.ui.theme.BitchatMotion.EMPHASIZED_MS
|
||||
),
|
||||
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.EMPHASIZED_MS),
|
||||
label = "msgOffset"
|
||||
)
|
||||
|
||||
@ -214,129 +222,25 @@ fun MessageItem(message: BitchatMessage, myPeerID: String) {
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = message.content,
|
||||
style = ChatVisualTokens.MessageBodyStyle,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(top = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatComposer(onSend: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
var text by remember { mutableStateOf("") }
|
||||
|
||||
val dictationLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val spoken = result.data
|
||||
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
?.firstOrNull()
|
||||
if (!spoken.isNullOrBlank()) {
|
||||
onSend(spoken.trim())
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun send() {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isNotEmpty()) {
|
||||
onSend(trimmed)
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(start = 24.dp, end = 24.dp, top = 4.dp, bottom = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
singleLine = true,
|
||||
textStyle = ChatVisualTokens.MessageBodyStyle.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { send() }),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(palette.inputSurface)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
if (text.isEmpty()) {
|
||||
Text(
|
||||
text = "message",
|
||||
style = ChatVisualTokens.MessageBodyStyle,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
dictationLauncher.launch(
|
||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(
|
||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
||||
)
|
||||
putExtra(RecognizerIntent.EXTRA_PROMPT, "speak your message")
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp)
|
||||
.size(34.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Mic,
|
||||
contentDescription = "dictate",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
when (message.type) {
|
||||
BitchatMessageType.Image -> ImageMessageItem(
|
||||
path = message.content.trim(),
|
||||
onOpen = onOpenImage
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { send() },
|
||||
enabled = text.isNotBlank(),
|
||||
modifier = Modifier
|
||||
.padding(start = 2.dp)
|
||||
.size(34.dp)
|
||||
.clip(CircleShape)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "send",
|
||||
tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary
|
||||
else palette.textTertiary
|
||||
BitchatMessageType.Audio -> VoiceNoteItem(path = message.content.trim())
|
||||
BitchatMessageType.File -> FileMessageChip(
|
||||
name = File(message.content.trim()).name,
|
||||
sizeBytes = File(message.content.trim()).length()
|
||||
)
|
||||
BitchatMessageType.Message -> Text(
|
||||
text = message.content,
|
||||
style = ChatVisualTokens.MessageBodyStyle,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(top = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendPublicMessage(mesh: WearMeshService, content: String) {
|
||||
mesh.sendMessage(content)
|
||||
AppStateStore.addPublicMessage(
|
||||
BitchatMessage(
|
||||
sender = mesh.nickname,
|
||||
content = content,
|
||||
timestamp = Date(),
|
||||
senderPeerID = mesh.myPeerID,
|
||||
deliveryStatus = DeliveryStatus.Sent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatTime(date: Date): String =
|
||||
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@ -21,30 +20,30 @@ import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.foundation.lazy.items
|
||||
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.ScreenScaffold
|
||||
import androidx.wear.compose.material3.Text
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import com.bitchat.watch.mesh.WearMeshService
|
||||
import com.bitchat.watch.ui.media.FullScreenImageViewer
|
||||
import com.bitchat.watch.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.watch.ui.theme.colorForPeer
|
||||
import java.util.Date
|
||||
|
||||
@Composable
|
||||
fun DmScreen(peerID: String) {
|
||||
fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
|
||||
val privateMessages by AppStateStore.privateMessages.collectAsState()
|
||||
val messages = privateMessages[peerID] ?: emptyList()
|
||||
val mesh = WearMeshService.peek()
|
||||
val myPeerID = mesh?.myPeerID ?: ""
|
||||
val palette = LocalBitchatPalette.current
|
||||
val listState = rememberScalingLazyListState()
|
||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
var viewerPath by remember { mutableStateOf<String?>(null) }
|
||||
val voice = rememberVoiceNoteController { path ->
|
||||
mesh?.let { sendVoiceNote(it, peerID, path) }
|
||||
}
|
||||
|
||||
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
|
||||
var sessionEstablished by remember {
|
||||
@ -73,17 +72,45 @@ fun DmScreen(peerID: String) {
|
||||
if (last != null && last.senderPeerID != myPeerID) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
previousCount = messages.size
|
||||
}
|
||||
|
||||
ScreenScaffold(scrollState = listState) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScalingLazyColumn(
|
||||
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScreenScaffold(scrollState = listState) {
|
||||
androidx.compose.foundation.lazy.LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 64.dp)
|
||||
reverseLayout = true,
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
top = 40.dp,
|
||||
bottom = 56.dp
|
||||
)
|
||||
) {
|
||||
items(messages.asReversed(), key = { it.id }) { message ->
|
||||
MessageItem(
|
||||
message = message,
|
||||
myPeerID = myPeerID,
|
||||
onOpenImage = { viewerPath = it }
|
||||
)
|
||||
}
|
||||
if (messages.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = if (sessionEstablished) "encrypted channel ready\nsay hi"
|
||||
else "setting up encryption…",
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color = palette.textTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -106,51 +133,21 @@ fun DmScreen(peerID: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (messages.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = if (sessionEstablished) "encrypted channel ready\nsay hi"
|
||||
else "setting up encryption…",
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color = palette.textTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(messages, key = { it.id }) { message ->
|
||||
MessageItem(message = message, myPeerID = myPeerID)
|
||||
}
|
||||
}
|
||||
ChatComposer(
|
||||
onSend = { text ->
|
||||
mesh?.let { sendPrivateMessage(it, peerID, nickname, text) }
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
|
||||
ChatActionBar(
|
||||
onKeyboard = onOpenTextInput,
|
||||
voice = voice,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 10.dp)
|
||||
)
|
||||
|
||||
VoiceRecordOverlay(voice)
|
||||
}
|
||||
|
||||
viewerPath?.let { path ->
|
||||
FullScreenImageViewer(path = path, onClose = { viewerPath = null })
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendPrivateMessage(
|
||||
mesh: WearMeshService,
|
||||
peerID: String,
|
||||
recipientNickname: String,
|
||||
content: String
|
||||
) {
|
||||
mesh.sendPrivateMessage(content, peerID, recipientNickname)
|
||||
AppStateStore.addPrivateMessage(
|
||||
peerID,
|
||||
BitchatMessage(
|
||||
sender = mesh.nickname,
|
||||
content = content,
|
||||
timestamp = Date(),
|
||||
isPrivate = true,
|
||||
recipientNickname = recipientNickname,
|
||||
senderPeerID = mesh.myPeerID,
|
||||
deliveryStatus = DeliveryStatus.Sent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
78
wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt
Normal file
78
wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt
Normal file
@ -0,0 +1,78 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import com.bitchat.watch.mesh.WearMeshService
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
|
||||
internal fun sendPublicMessage(mesh: WearMeshService, content: String) {
|
||||
mesh.sendMessage(content)
|
||||
AppStateStore.addPublicMessage(
|
||||
BitchatMessage(
|
||||
sender = mesh.nickname,
|
||||
content = content,
|
||||
timestamp = Date(),
|
||||
senderPeerID = mesh.myPeerID,
|
||||
deliveryStatus = DeliveryStatus.Sent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun sendPrivateMessage(
|
||||
mesh: WearMeshService,
|
||||
peerID: String,
|
||||
recipientNickname: String,
|
||||
content: String
|
||||
) {
|
||||
mesh.sendPrivateMessage(content, peerID, recipientNickname)
|
||||
AppStateStore.addPrivateMessage(
|
||||
peerID,
|
||||
BitchatMessage(
|
||||
sender = mesh.nickname,
|
||||
content = content,
|
||||
timestamp = Date(),
|
||||
isPrivate = true,
|
||||
recipientNickname = recipientNickname,
|
||||
senderPeerID = mesh.myPeerID,
|
||||
deliveryStatus = DeliveryStatus.Sent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a recorded voice note. Global chat: broadcast file packet. DM thread: Noise-encrypted
|
||||
* private file transfer. Local echo renders immediately (content = local path, type = Audio).
|
||||
*/
|
||||
internal fun sendVoiceNote(mesh: WearMeshService, peerID: String?, path: String) {
|
||||
val file = File(path)
|
||||
if (!file.isFile) return
|
||||
val packet = BitchatFilePacket(
|
||||
fileName = file.name,
|
||||
fileSize = file.length(),
|
||||
mimeType = "audio/mp4",
|
||||
content = file.readBytes()
|
||||
)
|
||||
if (peerID == null) {
|
||||
mesh.sendFileBroadcast(packet)
|
||||
} else {
|
||||
mesh.sendFilePrivateEncrypted(peerID, packet)
|
||||
}
|
||||
val echo = BitchatMessage(
|
||||
sender = mesh.nickname,
|
||||
content = path,
|
||||
type = BitchatMessageType.Audio,
|
||||
timestamp = Date(),
|
||||
isPrivate = peerID != null,
|
||||
senderPeerID = mesh.myPeerID,
|
||||
deliveryStatus = DeliveryStatus.Sent
|
||||
)
|
||||
if (peerID == null) {
|
||||
AppStateStore.addPublicMessage(echo)
|
||||
} else {
|
||||
AppStateStore.addPrivateMessage(peerID, echo)
|
||||
}
|
||||
}
|
||||
156
wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt
Normal file
156
wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt
Normal file
@ -0,0 +1,156 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.speech.RecognizerIntent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.wear.compose.material3.Icon
|
||||
import androidx.wear.compose.material3.IconButton
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.Text
|
||||
import com.bitchat.watch.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||
|
||||
/**
|
||||
* Full-screen text input: field auto-focused so the watch IME (with its built-in dictation)
|
||||
* opens immediately, plus a dedicated dictation button using the system speech recognizer.
|
||||
*/
|
||||
@Composable
|
||||
fun TextInputScreen(onSend: (String) -> Unit) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
var text by remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
|
||||
|
||||
val dictationLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val spoken = result.data
|
||||
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
?.firstOrNull()
|
||||
if (!spoken.isNullOrBlank()) {
|
||||
onSend(spoken.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun send() {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isNotEmpty()) {
|
||||
keyboardController?.hide()
|
||||
onSend(trimmed)
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
singleLine = true,
|
||||
textStyle = ChatVisualTokens.MessageBodyStyle.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { send() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(palette.inputSurface)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
if (text.isEmpty()) {
|
||||
Text(
|
||||
text = "message",
|
||||
style = ChatVisualTokens.MessageBodyStyle,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
dictationLauncher.launch(
|
||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(
|
||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
||||
)
|
||||
putExtra(RecognizerIntent.EXTRA_PROMPT, "speak your message")
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = Modifier.size(38.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Mic,
|
||||
contentDescription = "dictate",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { send() },
|
||||
enabled = text.isNotBlank(),
|
||||
modifier = Modifier.size(38.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "send",
|
||||
tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary
|
||||
else palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bitchat.android.features.voice.VoiceRecorder
|
||||
import com.bitchat.android.features.voice.normalizeAmplitudeSample
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val MAX_RECORDING_MS = 10_000L
|
||||
private const val MIN_RECORDING_MS = 600L
|
||||
private const val AMPLITUDE_POLL_MS = 80L
|
||||
private const val LIVE_BARS = 32
|
||||
|
||||
/**
|
||||
* Push-to-talk recording controller: start on press, stop on release, 10 s cap, ~80 ms
|
||||
* amplitude polls into a rolling live-waveform buffer. Hoisted to screen level so the
|
||||
* full-screen overlay can render outside the edge-button slot.
|
||||
*/
|
||||
class VoiceNoteController(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val onSendVoice: (String) -> Unit
|
||||
) {
|
||||
private val recorder = VoiceRecorder(context.applicationContext)
|
||||
|
||||
var recording by mutableStateOf(false)
|
||||
private set
|
||||
var elapsedMs by mutableLongStateOf(0L)
|
||||
private set
|
||||
var liveSamples by mutableStateOf(FloatArray(LIVE_BARS))
|
||||
private set
|
||||
|
||||
private var pollJob: Job? = null
|
||||
private var startedAt = 0L
|
||||
|
||||
fun start() {
|
||||
if (recording) return
|
||||
recorder.start() ?: return
|
||||
startedAt = System.currentTimeMillis()
|
||||
elapsedMs = 0L
|
||||
liveSamples = FloatArray(LIVE_BARS)
|
||||
recording = true
|
||||
pollJob = scope.launch {
|
||||
while (true) {
|
||||
delay(AMPLITUDE_POLL_MS)
|
||||
val amp = normalizeAmplitudeSample(recorder.pollAmplitude())
|
||||
liveSamples = liveSamples.copyOfRange(1, LIVE_BARS) + amp
|
||||
val elapsed = System.currentTimeMillis() - startedAt
|
||||
elapsedMs = elapsed
|
||||
if (elapsed >= MAX_RECORDING_MS) {
|
||||
stop(send = true)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(send: Boolean) {
|
||||
if (!recording) return
|
||||
recording = false
|
||||
pollJob?.cancel()
|
||||
pollJob = null
|
||||
val file = recorder.stop()
|
||||
val elapsed = System.currentTimeMillis() - startedAt
|
||||
if (send && file != null && elapsed >= MIN_RECORDING_MS) {
|
||||
onSendVoice(file.absolutePath)
|
||||
} else {
|
||||
file?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberVoiceNoteController(onSendVoice: (String) -> Unit): VoiceNoteController {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
return remember { VoiceNoteController(context, scope, onSendVoice) }
|
||||
}
|
||||
306
wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt
Normal file
306
wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt
Normal file
@ -0,0 +1,306 @@
|
||||
package com.bitchat.watch.ui.media
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaPlayer
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.wear.compose.material3.Icon
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.Text
|
||||
import com.bitchat.android.features.voice.AudioWaveformExtractor
|
||||
import com.bitchat.android.features.voice.VoiceWaveformCache
|
||||
import com.bitchat.watch.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Compact inline image thumbnail; tap opens the full-screen viewer.
|
||||
*/
|
||||
@Composable
|
||||
fun ImageMessageItem(path: String, onOpen: (String) -> Unit) {
|
||||
val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
|
||||
if (bitmap == null) {
|
||||
FileMessageChip(name = File(path).name, sizeBytes = File(path).length())
|
||||
return
|
||||
}
|
||||
Image(
|
||||
painter = BitmapPainter(bitmap.asImageBitmap()),
|
||||
contentDescription = "image",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.widthIn(max = 120.dp)
|
||||
.aspectRatio(
|
||||
(bitmap.width.toFloat() / bitmap.height.toFloat()).coerceIn(0.6f, 1.8f)
|
||||
)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable { onOpen(path) }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen image viewer (mirrors the phone's FullScreenImageViewer): black surface,
|
||||
* fit-to-screen, tap or swipe-back to dismiss.
|
||||
*/
|
||||
@Composable
|
||||
fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable { onClose() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
painter = BitmapPainter(bitmap.asImageBitmap()),
|
||||
contentDescription = "image fullscreen",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "close",
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 24.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice-note bubble: play/pause + waveform (120 bins, extracted locally like the phone) +
|
||||
* duration/progress. Playback via MediaPlayer.
|
||||
*/
|
||||
@Composable
|
||||
fun VoiceNoteItem(path: String) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
var samples by remember { mutableStateOf(VoiceWaveformCache.get(path)) }
|
||||
var playing by remember { mutableStateOf(false) }
|
||||
var progress by remember { mutableFloatStateOf(0f) }
|
||||
var durationMs by remember { mutableIntStateOf(0) }
|
||||
val player = remember { MediaPlayer() }
|
||||
|
||||
LaunchedEffect(path) {
|
||||
if (samples == null) {
|
||||
AudioWaveformExtractor.extractAsync(path) { extracted ->
|
||||
if (extracted != null) {
|
||||
VoiceWaveformCache.put(path, extracted)
|
||||
samples = extracted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(path) {
|
||||
runCatching {
|
||||
player.reset()
|
||||
player.setDataSource(path)
|
||||
player.setOnCompletionListener {
|
||||
playing = false
|
||||
progress = 0f
|
||||
}
|
||||
player.prepare()
|
||||
durationMs = player.duration
|
||||
}
|
||||
onDispose {
|
||||
runCatching { if (player.isPlaying) player.stop() }
|
||||
runCatching { player.release() }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(playing) {
|
||||
while (playing) {
|
||||
progress = if (durationMs > 0) player.currentPosition.toFloat() / durationMs else 0f
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(palette.inputSurface)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(26.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.clickable {
|
||||
if (playing) {
|
||||
player.pause()
|
||||
playing = false
|
||||
} else {
|
||||
runCatching { player.start() }
|
||||
playing = true
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (playing) {
|
||||
Canvas(modifier = Modifier.size(10.dp)) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
drawRoundRect(
|
||||
color = Color.Black,
|
||||
topLeft = Offset(0f, 0f),
|
||||
size = Size(w * 0.35f, h),
|
||||
cornerRadius = CornerRadius(1.dp.toPx())
|
||||
)
|
||||
drawRoundRect(
|
||||
color = Color.Black,
|
||||
topLeft = Offset(w * 0.65f, 0f),
|
||||
size = Size(w * 0.35f, h),
|
||||
cornerRadius = CornerRadius(1.dp.toPx())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = "play",
|
||||
tint = Color.Black,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
WaveformBars(
|
||||
samples = samples,
|
||||
progress = progress,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.weight(1f)
|
||||
.height(22.dp),
|
||||
activeColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveColor = palette.textTertiary.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = formatDuration(if (playing) (durationMs * progress).toInt() else durationMs),
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color = palette.textTertiary,
|
||||
modifier = Modifier.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WaveformBars(
|
||||
samples: FloatArray?,
|
||||
progress: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
activeColor: Color,
|
||||
inactiveColor: Color
|
||||
) {
|
||||
Canvas(modifier = modifier) {
|
||||
val bars = 32
|
||||
val values = samples?.let { com.bitchat.android.features.voice.resampleWave(it, bars) }
|
||||
?: FloatArray(bars) { 0.3f }
|
||||
val barWidth = size.width / (bars * 2 - 1)
|
||||
for (i in 0 until bars) {
|
||||
val v = values.getOrElse(i) { 0f }.coerceIn(0.08f, 1f)
|
||||
val barHeight = size.height * v
|
||||
val x = i * barWidth * 2
|
||||
drawRoundRect(
|
||||
color = if (i.toFloat() / bars <= progress) activeColor else inactiveColor,
|
||||
topLeft = Offset(x, (size.height - barHeight) / 2f),
|
||||
size = Size(barWidth, barHeight),
|
||||
cornerRadius = CornerRadius(barWidth / 2f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact chip for non-media files.
|
||||
*/
|
||||
@Composable
|
||||
fun FileMessageChip(name: String, sizeBytes: Long) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(palette.inputSurface)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = name,
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = formatSize(sizeBytes),
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(ms: Int): String {
|
||||
val totalSeconds = (ms / 1000).coerceAtLeast(0)
|
||||
return "%d:%02d".format(totalSeconds / 60, totalSeconds % 60)
|
||||
}
|
||||
|
||||
private fun formatSize(bytes: Long): String = when {
|
||||
bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576f)
|
||||
bytes >= 1024 -> "%.1f KB".format(bytes / 1024f)
|
||||
else -> "$bytes B"
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user