mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 00:25:19 +00:00
* Fix lock glyph alignment, privacy-caption band, and empty-state wrapping - Message-row locks: align to first text baseline instead of a hardcoded top padding that left the lock ~4pt below the line's visual center - Header/caption locks: 1pt optical lift (lock.fill ink is bottom-heavy; geometric centering reads low); seal badge stays untouched - DM privacy caption: sit on the themed surface like the rest of the bottom chrome instead of painting its own orange band - Empty-state lines: non-breaking spaces so the closing * can't orphan and 'bitchat/ for help' can't break right after the slash Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Unify sheet close buttons, widen tiny tap targets, handle long nicknames - New SheetCloseButton component: one glyph size/weight (13 semibold), 32pt visual box, 44pt hit target; adopted by all 7 sheets (sizes had drifted across 12/13/14pt, two had no frame at all) - Favorite star buttons get real tap targets (peer list + DM header) - DM header nickname: single line with middle truncation instead of wrapping into the fixed-height header; peer-list names truncate tail - Geohash people rows: leading glyph 12 -> 10 to match mesh rows - Sidebar lock glyphs get the same optical lift as the DM header Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Make channel switching, voice notes, and header actions work under VoiceOver - Channel rows in the location sheet are now single activatable buttons (label + selected trait + switch hint) with the bookmark toggle mirrored as a named accessibility action; bookmark buttons labeled - Voice-note mic: press-and-hold drag gestures can't be activated by VoiceOver, so the default accessibility action now toggles start/stop-and-send; announces 'recording' state; localized labels - Attachment button: camera (long-press) path exposed as a named action; labels localized instead of hardcoded English - People-count button announces connected vs no-one-reachable (was color-only); verification QR button gains a spoken name (.help is only a hint on iOS); bitchat/ logo exposes its tap-for-app-info as a button (panic triple-tap stays undiscoverable on purpose) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Theme-correctness sweep: palette colors everywhere, AX-size header growth - Fingerprint/verification sheet cards: palette-tinted boxes instead of fixed gray bands that ignored matrix green and occluded glass - Voice-note card: palette background (translucent) instead of opaque white/black; waveform + payment chips + image placeholder follow suit - .secondary/.primary/Color.blue swapped for palette.secondary/primary/ accentBlue across location sheets, people sheets, message captions, and the header count (system gray read wrong under matrix green) - Autocomplete/command rows: dropped the uniform gray wash that dulled the themed overlay panel - 'tap to reveal' caption follows the theme font instead of hardcoding monospaced - Headers use minHeight so two-line accessibility text sizes grow the bar instead of clipping inside it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix main header expanding to fill the screen The header bar's fixed height was load-bearing: its children fill the bar with .frame(maxHeight: .infinity) tap targets, so switching to an open-ended minHeight let the header expand to swallow all available vertical space, centering the title mid-screen and crushing the timeline into the composer. Restore the fixed height — headerHeight is a @ScaledMetric, so it already grows with Dynamic Type. Reproduced and verified both layouts with an offscreen render harness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * DM header: floating glass panel instead of muddy orange wash under glass Orange at 14% over the backdrop gradient reads as a gray-beige band, not a privacy signature. Under liquid glass the DM header now uses the same floating chrome panel as the main header; the private signature is already carried by the orange lock, caption, and composer accents. Matrix keeps its orange wash over the opaque themed surface, unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
122 lines
4.5 KiB
Swift
122 lines
4.5 KiB
Swift
import SwiftUI
|
|
import AVFoundation
|
|
|
|
struct VoiceNoteView: View {
|
|
private let url: URL
|
|
private let isSending: Bool
|
|
private let sendProgress: Double?
|
|
private let onCancel: (() -> Void)?
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@ThemedPalette private var palette
|
|
@StateObject private var playback: VoiceNotePlaybackController
|
|
@State private var waveform: [Float] = []
|
|
|
|
init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) {
|
|
self.url = url
|
|
self.isSending = isSending
|
|
self.sendProgress = sendProgress
|
|
self.onCancel = onCancel
|
|
_playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url))
|
|
}
|
|
|
|
private var samples: [Float] {
|
|
if waveform.isEmpty {
|
|
return Array(repeating: 0.25, count: 64)
|
|
}
|
|
return waveform
|
|
}
|
|
|
|
private var backgroundColor: Color {
|
|
// Palette-based and slightly translucent so the card doesn't sit as
|
|
// an opaque white/black box over the glass gradient.
|
|
palette.background.opacity(colorScheme == .dark ? 0.6 : 0.7)
|
|
}
|
|
|
|
private var borderColor: Color {
|
|
colorScheme == .dark ? palette.accent.opacity(0.3) : palette.accent.opacity(0.2)
|
|
}
|
|
|
|
private var playbackLabel: String {
|
|
guard playback.duration.isFinite else { return "--:--" }
|
|
let seconds = playback.isPlaying ? playback.remainingSeconds : playback.roundedDuration
|
|
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Button(action: playback.togglePlayback) {
|
|
Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill")
|
|
.foregroundColor(.white)
|
|
.frame(width: 36, height: 36)
|
|
.background(Circle().fill(palette.accent))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(
|
|
playback.isPlaying
|
|
? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback")
|
|
: String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note")
|
|
)
|
|
.accessibilityValue(playbackLabel)
|
|
|
|
WaveformView(
|
|
samples: samples,
|
|
playbackProgress: playback.progress,
|
|
sendProgress: sendProgress,
|
|
onSeek: { fraction in
|
|
playback.seek(to: fraction)
|
|
},
|
|
isInteractive: playback.isPlaying
|
|
)
|
|
|
|
Text(playbackLabel)
|
|
.bitchatFont(size: 13)
|
|
.foregroundColor(palette.secondary)
|
|
|
|
if let onCancel = onCancel, isSending {
|
|
Button(action: onCancel) {
|
|
Image(systemName: "xmark")
|
|
.font(.bitchatSystem(size: 12, weight: .bold))
|
|
.frame(width: 28, height: 28)
|
|
.background(Circle().fill(Color.red.opacity(0.9)))
|
|
.foregroundColor(.white)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(
|
|
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
|
|
)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(backgroundColor)
|
|
.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.3 : 0.1), radius: 6, x: 0, y: 2)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(borderColor, lineWidth: 1)
|
|
)
|
|
.task {
|
|
// Defer loading to let UI settle after view appears
|
|
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
|
|
playback.loadDuration()
|
|
await withCheckedContinuation { continuation in
|
|
WaveformCache.shared.waveform(for: url, completion: { bins in
|
|
waveform = bins
|
|
continuation.resume()
|
|
})
|
|
}
|
|
}
|
|
.onChange(of: url) { newValue in
|
|
WaveformCache.shared.waveform(for: newValue, completion: { bins in
|
|
self.waveform = bins
|
|
})
|
|
playback.replaceURL(newValue)
|
|
}
|
|
.onDisappear {
|
|
playback.stop()
|
|
}
|
|
}
|
|
}
|