Files
chanora/apps/chanora_flutter/lib/main.dart
T
Edison Jwa 5f1423c349 feat(voice): unified mobile voice bar with gesture-isolated PTT row (#22)
* feat(voice): unified mobile voice bar with gesture-isolated PTT row

Replace separate VoiceStatusChip + VoicePttButton with a single
CompactVoiceBar widget that combines both into a two-row layout:

- Control row (tap): status text, mute, deafen, settings chevron
- PTT row (hold): full-width hold-to-talk, shown only in PTT mode

Gesture isolation prevents mis-touch between rows: the control row
uses tap-only InkWell/IconButton while the PTT row uses a raw
Listener for pointer-down/up events.

Key changes:
- Add CompactVoiceBar widget with state-colored container (normal,
  muted, talk-power-blocked)
- Remove mute/deafen IconButtons from AppBar headerActions
- Restructure voice details sheet into primary section + collapsible
  ExpansionTiles (audio processing, PTT capability, debug)
- Optimistic state updates for mute/deafen to eliminate tap delay
- Instant PTT visual feedback (no AnimatedContainer fade)
- Constant geometry across all states (no layout shift on toggle)

* fix(voice): preserve current PTT button format

* feat(voice): move mute/deafen controls into VoiceStatusChip

* fix(voice): ensure consistent chip height across mute states

Remove isSelected/selectedIcon from IconButtons inside VoiceStatusChip.
Material 3 toggle IconButtons (_SelectableIconButton) can vary in height
when the selected state changes due to tap target sizing. Use simple
conditional icons instead and set shrinkWrap tap target size with tight
constraints for stable 40x40 buttons regardless of state.

* fix(voice): remove leftover duplicate mute/deafen buttons in VoiceStatusChip

* fix(voice): replace unsafe stereo cast with bytemuck and localise talk-power tooltip

Replace the raw-pointer `&mut [(f32, f32)]` to `&mut [f32]` cast in
the oboe output callback with `bytemuck::cast_slice_mut`, eliminating
the unsafe block and relying on bytemuck compile-time NoUninit
verification instead.

Add voiceTalkPowerBlocked l10n key (en + zh) and replace the only
remaining hard-coded English tooltip in VoiceStatusChip with it.
2026-06-05 20:58:16 +09:00

2739 lines
89 KiB
Dart

// Chanora Flutter application — External Beta build.
//
// Builds on Internal Beta v0.3.0-beta.1:
// * server password field
// * bookmark list with add / connect / delete actions
// * channel tap-to-join with optional channel password
// * self input + output mute toggles + master output gain slider
// * diagnostics dialog + reconnect banner + identity persistence
// (all carried over from v0.3.0-beta.1)
import 'dart:async';
import 'dart:io' show Platform, Process;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart';
import 'services/app_bootstrap.dart';
import 'services/audio_lifecycle_service.dart';
import 'services/channel_join_error_mapper.dart';
import 'services/connection_phase_state.dart';
import 'services/ios_permissions_service.dart';
import 'services/macos_permissions_service.dart';
import 'services/prefetch_debouncer.dart';
import 'services/snapshot_state_mapper.dart';
import 'services/ts3_server_link.dart';
import 'services/ui_preferences_service.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/frb_generated.dart';
import 'src/rust/lib.dart' as rust_err;
import 'widgets/permission_state_banner.dart';
import 'widgets/audio_processing_config_state.dart';
import 'widgets/chat_views.dart';
import 'widgets/client_info_sheet.dart';
import 'widgets/connect_widgets.dart';
import 'widgets/input_dialogs.dart';
import 'widgets/snapshot_view.dart';
import 'widgets/voice_platform.dart';
import 'widgets/voice_bar.dart';
import 'widgets/voice_compact.dart';
import 'widgets/voice_settings.dart';
import 'package:share_plus/share_plus.dart';
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
const MethodChannel _iosPlatformChannel = MethodChannel('chanora/ios_platform');
const Color _appSurfaceColor = Color(0xFFFFFBFE);
/// Top padding for macOS to clear traffic-light buttons.
const double _macOSTrafficLightPad = 56.0;
/// Public version string shown in the About dialog. Resolved at
/// app init by combining a hardcoded semver baseline (kept in sync
/// with the git tag and pubspec.yaml's `version:` field) with the
/// platform-canonical build counter from `package_info_plus`.
///
/// Why hardcode the semver instead of reading the whole string
/// from `package_info_plus`: iOS rejects non-numeric characters
/// in `CFBundleShortVersionString` and Flutter therefore strips
/// `-rc.8` to `.8` when populating the Info.plist field. The
/// resulting `1.0.0.8` is technically valid on the App Store but
/// useless to humans tracking pre-release builds.
/// `package_info_plus.version` reflects that mangled value. The
/// build counter (`CFBundleVersion` / Android `versionCode`) does
/// pass through unmodified, so we use platform info for the
/// `+<build>` suffix only and pair it with the human-readable
/// semver baseline that this codebase already maintains as the
/// canonical release identity.
///
/// Bump [appSemverBaseline] whenever the semver portion of
/// pubspec.yaml advances (e.g. rc.8 -> rc.9 -> 1.0.0). The
/// build-counter suffix changes automatically on every pubspec
/// `+<n>` bump because Flutter writes it into Info.plist.
String _kAppVersion = appSemverBaseline;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await RustLib.init();
unawaited(wireStorage());
unawaited(wireConnectivity());
wireAudioLifecycle();
await configureBundledVadModels();
runApp(const ChanoraApp());
unawaited(_finishDeferredStartup());
}
Future<void> _finishDeferredStartup() async {
try {
_kAppVersion = await resolveAppVersion();
} catch (_) {}
}
class ChanoraApp extends StatefulWidget {
const ChanoraApp({super.key});
@override
State<ChanoraApp> createState() => _ChanoraAppState();
}
class _ChanoraAppState extends State<ChanoraApp> {
final UiPreferencesService _uiPreferences = const UiPreferencesService();
ThemeMode _themeMode = ThemeMode.system;
@override
void initState() {
super.initState();
unawaited(_loadThemeMode());
}
Future<void> _loadThemeMode() async {
try {
final settings = await _uiPreferences.loadSettings();
if (!mounted) return;
setState(() {
_themeMode = settings.themeMode.toFlutterThemeMode();
});
} catch (_) {}
}
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarColor: _appSurfaceColor,
systemNavigationBarDividerColor: _appSurfaceColor,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: MaterialApp(
onGenerateTitle: (ctx) => AppL10n.of(ctx).appTitle,
// No "DEBUG" banner in the top-right corner. This is purely
// cosmetic for the developer-build experience; release builds
// never render it regardless of this flag.
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(0xFF3F51B5),
scaffoldBackgroundColor: _appSurfaceColor,
),
darkTheme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(0xFF3F51B5),
brightness: Brightness.dark,
),
themeMode: _themeMode,
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: _BetaHome(
themeMode: _themeMode,
onThemeModeChanged: _setThemeMode,
),
),
);
}
Future<void> _setThemeMode(ThemeMode themeMode) async {
setState(() {
_themeMode = themeMode;
});
try {
await _uiPreferences.saveThemeMode(themeMode.toUiThemeMode());
} catch (_) {}
}
}
extension on UiThemeMode {
ThemeMode toFlutterThemeMode() {
return switch (this) {
UiThemeMode.system => ThemeMode.system,
UiThemeMode.light => ThemeMode.light,
UiThemeMode.dark => ThemeMode.dark,
};
}
}
extension on ThemeMode {
UiThemeMode toUiThemeMode() {
return switch (this) {
ThemeMode.system => UiThemeMode.system,
ThemeMode.light => UiThemeMode.light,
ThemeMode.dark => UiThemeMode.dark,
};
}
}
class ChanoraThemeModeMenu extends StatelessWidget {
const ChanoraThemeModeMenu({
super.key,
required this.themeMode,
required this.onThemeModeChanged,
});
final ThemeMode themeMode;
final Future<void> Function(ThemeMode mode) onThemeModeChanged;
@override
Widget build(BuildContext context) {
return PopupMenuButton<ThemeMode>(
tooltip: 'Theme',
icon: const Icon(Icons.palette_outlined),
initialValue: themeMode,
onSelected: (mode) {
unawaited(onThemeModeChanged(mode));
},
itemBuilder: (context) => const [
PopupMenuItem(value: ThemeMode.system, child: Text('System')),
PopupMenuItem(value: ThemeMode.light, child: Text('Light')),
PopupMenuItem(value: ThemeMode.dark, child: Text('Dark')),
],
);
}
}
class ChanoraMobileScaffold extends StatelessWidget {
const ChanoraMobileScaffold({
super.key,
required this.compactIdleChrome,
required this.canDisconnect,
required this.title,
required this.actions,
required this.onDisconnect,
required this.compactHeader,
required this.body,
this.disconnectTooltip = 'Disconnect',
});
final bool compactIdleChrome;
final bool canDisconnect;
final Widget? title;
final List<Widget> actions;
final VoidCallback onDisconnect;
final Widget compactHeader;
final Widget body;
final String disconnectTooltip;
@override
Widget build(BuildContext context) {
final paddedBody = SafeArea(
top: compactIdleChrome,
child: Padding(
padding: const EdgeInsets.all(16),
child: compactIdleChrome
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
compactHeader,
const SizedBox(height: 8),
Expanded(child: body),
],
)
: body,
),
);
return Scaffold(
appBar: compactIdleChrome
? null
: AppBar(
leading: canDisconnect
? IconButton(
tooltip: disconnectTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: onDisconnect,
)
: null,
leadingWidth: canDisconnect ? 44 : null,
titleSpacing: canDisconnect ? 4 : null,
title: title,
actions: actions,
),
body: paddedBody,
);
}
}
class _BetaHome extends StatefulWidget {
const _BetaHome({required this.themeMode, required this.onThemeModeChanged});
final ThemeMode themeMode;
final Future<void> Function(ThemeMode mode) onThemeModeChanged;
@override
State<_BetaHome> createState() => _BetaHomeState();
}
class _ReceivedPoke {
const _ReceivedPoke({
required this.senderName,
required this.message,
required this.receivedAt,
});
final String senderName;
final String message;
final DateTime receivedAt;
}
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
static const _wideBreakpoint = 600.0;
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
final _passwordCtl = TextEditingController();
ConnectionPhase _phase = ConnectionPhase.idle;
bool get _serverReachable => _phase.isServerReachable;
rust.BridgeSnapshot? _snapshot;
final List<String> _uiDiagnostics = [];
rust.BridgeAudioStats? _audioStats;
double? _inputLevel;
Timer? _statsTimer;
bool _snapshotRefreshInFlight = false;
bool _snapshotRefreshQueued = false;
bool _snapshotRefreshQueuedRecordActivity = false;
bool _snapshotRefreshQueuedReportErrors = false;
int _connectionEpoch = 0;
StreamSubscription<rust.BridgeEvent>? _eventsSub;
StreamSubscription<double>? _inputLevelSub;
late final PrefetchDebouncer _prefetch;
// v1 voice subsystem state (SDD-094/095/096/097). Driven by
// BridgeEvent::VoiceState.
bool _inChannel = false;
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
bool _hardMute = false;
bool _hardMuteByPermission = false;
bool _hardMuteByTalkPower = false;
bool _permissionHardMuteClearInFlight = false;
int _releaseTailMs = 200;
BigInt? _currentVoiceChannelId;
BigInt? _pendingVoiceChannelId;
bool _canJoinVoiceChannel = true;
bool _voiceStateInitialized = false;
String? _lostReason;
int? _reconnectAttempt;
int? _reconnectDelay;
bool _inputMuted = false;
bool _outputMuted = false;
// Desktop PTT capability badge state (gen2 v0.9.3 / SDD-091).
// Populated by `BridgeEvent.PttCapability`. Defaults match the
// universal `FocusedPttBackend::focused()` descriptor so the UI
// shows an honest baseline even before the first event arrives.
String _pttLevel = 'L0Focused';
String _pttBackendId = 'focused';
String _pttBoundInputClass = 'keyboard';
// Last platform-neutral key label the user saved in the
// `PttBindingCaptureDialog` (e.g. "Space", "F10",
// "mouse-side-button:8"). Surfaced next to the capability
// badge so the user can remember which key drives PTT. The
// bridge-side `PttController` (SDD-088) holds the authoritative
// binding; this is a display-only cache that resets on app
// restart. Per DEC-027 the raw OS key code never lives here —
// only the platform-neutral label that already crossed into
// the Rust side.
String _pttBoundKeyLabel = '';
final Set<LogicalKeyboardKey> _focusedPttHeldKeys = <LogicalKeyboardKey>{};
List<rust.BridgeBookmark> _bookmarks = const [];
final List<ChatEntry> _chatMessages = [];
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
int _chatUnread = 0;
bool _chatOpen = false;
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
const [],
);
bool _pokeSnackBarVisible = false;
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
// Constructed at startup so cold-launch state is captured before the
// first voice_join attempt. On non-Android hosts the service
// short-circuits to "granted" and never wires the MethodChannel
// (see AndroidPermissionsService for the platform branch).
final AndroidPermissionsService _androidPermissions =
AndroidPermissionsService();
final IosPermissionsService _iosPermissions = IosPermissionsService();
// SRS-198 / SRS-297 / SRS-300: macOS Input Monitoring, Local Network,
// and Notifications permission service. On non-macOS hosts the service
// short-circuits to "granted" / "unsupported" and never wires the
// MethodChannel.
final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService();
final UiPreferencesService _uiPreferences = const UiPreferencesService();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
HardwareKeyboard.instance.addHandler(_handleFocusedPttKey);
_prefetch = PrefetchDebouncer(onPrefetch: _onPrefetchServer);
_hostCtl.addListener(_onHostEdited);
_eventsSub = rust.eventsStream().listen(_onEvent);
_inputLevelSub = rust.inputLevelStream().listen((level) {
if (mounted) setState(() => _inputLevel = level);
});
// SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged
// events as early as possible so the listen-only banner reflects
// the system state on first frame.
_androidPermissions.start();
unawaited(_iosPermissions.start());
_androidPermissions.recordAudioState.addListener(
_onRecordAudioPermissionChanged,
);
_iosPermissions.recordAudioState.addListener(
_onRecordAudioPermissionChanged,
);
// SRS-198 / SRS-297 / SRS-300: start macOS permission service.
// On non-macOS this is a no-op. On macOS, it checks Input
// Monitoring state and begins polling for changes so the PTT
// capability badge upgrades from L0Focused → L1MacOSEventTap
// when the user grants the permission in System Settings.
_macOSPermissions.start();
_macOSPermissions.pttCapabilityState.addListener(
_onMacOSPttCapabilityChanged,
);
_macOSPermissions.checkInitialStates();
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_requestRecordAudioOnStartup());
});
unawaited(_reloadBookmarks());
unawaited(_hydratePttBinding());
unawaited(_loadUiSettings());
}
Future<void> _loadUiSettings() async {
try {
final settings = await _uiPreferences.loadSettings();
if (settings.host.isNotEmpty) {
_hostCtl.text = settings.host;
}
if (settings.nickname.isNotEmpty) {
_nickCtl.text = settings.nickname;
}
if (settings.host.isNotEmpty) {
_prefetch.schedule(settings.host);
}
} catch (_) {}
}
void _onHostEdited() {
_prefetch.schedule(_hostCtl.text);
}
Future<void> _onPrefetchServer(String host) async {
try {
await rust.prefetchServer(host: host);
} catch (e) {
_recordUiDiagnostic('prefetch server', e);
}
}
Future<void> _saveUiSettings({String? host, String? nickname}) async {
try {
await _uiPreferences.saveSettings(host: host, nickname: nickname);
} catch (_) {}
}
Future<void> _requestRecordAudioOnStartup() async {
try {
if (Platform.isAndroid) {
final permsExplained = await _uiPreferences.hasExplainedPermissions();
if (!permsExplained) {
if (!mounted) return;
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(AppL10n.of(ctx).startupPermissionsTitle),
content: Text(AppL10n.of(ctx).startupPermissionsBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(AppL10n.of(ctx).startupPermissionsNotNow),
),
FilledButton(
onPressed: () {
Navigator.pop(ctx);
_androidPermissions.ensureStartupPermissions();
},
child: Text(AppL10n.of(ctx).startupPermissionsAllow),
),
],
),
);
await _uiPreferences.markPermissionsExplained();
} else {
await _androidPermissions.ensureStartupPermissions();
}
}
} catch (_) {}
}
ValueListenable<AndroidRecordAudioPermissionState>
get _activeRecordAudioState => Platform.isIOS
? _iosPermissions.recordAudioState
: _androidPermissions.recordAudioState;
Future<AndroidRecordAudioPermissionState> _ensureActiveRecordAudio() {
return Platform.isIOS
? _iosPermissions.ensureRecordAudio()
: _androidPermissions.ensureRecordAudio();
}
Future<void> _openActivePermissionSettings() {
return Platform.isIOS
? _iosPermissions.openAppSettings()
: _androidPermissions.openAppSettings();
}
void _onRecordAudioPermissionChanged() {
if (_activeRecordAudioState.value ==
AndroidRecordAudioPermissionState.granted) {
unawaited(_clearPermissionHardMute());
}
}
/// SRS-198 / SRS-297 / SRS-300 / SDD-091: React to macOS Input
/// Monitoring state changes by updating the PTT capability level.
/// When the macOS permissions service detects that Input Monitoring
/// has been granted (via polling), it emits `L1MacOSEventTap` which
/// overrides the bridge-emitted `L0Focused` default.
void _onMacOSPttCapabilityChanged() {
final level = _macOSPermissions.pttCapabilityState.value;
// Only override if the macOS service has a resolved state
// different from the current bridge-emitted level, and only
// on macOS.
if (_isMacOS && level != _pttLevel) {
setState(() {
_pttLevel = level;
if (level == 'L1MacOSEventTap') {
_pttBackendId = 'macos-event-tap';
} else if (level == 'L0Focused') {
_pttBackendId = 'focused';
}
});
}
}
Future<void> _clearPermissionHardMute() async {
if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return;
_permissionHardMuteClearInFlight = true;
try {
await rust.setHardMute(muted: false);
if (!mounted || !_hardMuteByPermission) return;
setState(() {
_hardMute = false;
_hardMuteByPermission = false;
});
} catch (e) {
if (!mounted) return;
_showUiError('clear permission mute', e);
} finally {
_permissionHardMuteClearInFlight = false;
}
}
void _recordUiDiagnostic(String area, Object error) {
final line = '${DateTime.now().toIso8601String()} [$area] $error';
debugPrint('chanora: $line');
_uiDiagnostics.add(line);
if (_uiDiagnostics.length > 100) {
_uiDiagnostics.removeRange(0, _uiDiagnostics.length - 100);
}
}
void _showUiError(String area, Object error) {
if (!mounted) return;
_showUiErrorSnackBar(area: area, error: error);
}
void _showUiErrorSnackBar({
required String area,
required Object error,
String? displayMessage,
}) {
_recordUiDiagnostic(area, error);
final l10n = AppL10n.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(
displayMessage ?? l10n.statusError(error.toString()),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
);
}
bool _handleFocusedPttKey(KeyEvent event) {
final label = pttDisplayLabelForKey(event.logicalKey);
final isBoundKey =
_pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel;
if (event is KeyUpEvent && _focusedPttHeldKeys.contains(event.logicalKey)) {
if (_focusedPttHeldKeys.remove(event.logicalKey)) {
unawaited(_setPtt(false));
}
return true;
}
if (_pttBackendId != 'focused' ||
!_serverReachable ||
!_inChannel ||
_transmitMode != rust.BridgeTransmitMode.ptt ||
!isBoundKey) {
return false;
}
if (event is KeyDownEvent) {
if (_focusedPttHeldKeys.add(event.logicalKey)) {
unawaited(_setPtt(true));
}
return true;
}
return false;
}
void _releaseFocusedPttIfHeld() {
if (_focusedPttHeldKeys.isEmpty) return;
_focusedPttHeldKeys.clear();
unawaited(_setPtt(false, reportError: false));
}
/// Hydrate the bound-key display state from the bridge so the
/// Voice Bar shows the user's persisted hotkey label immediately
/// at launch — without having to wait for them to open the
/// binding dialog. The bridge returns empty strings when no
/// binding has ever been saved.
Future<void> _hydratePttBinding() async {
try {
final binding = await rust.getPttBinding();
if (!mounted) return;
if (binding.keyLabel.isNotEmpty || binding.inputClass.isNotEmpty) {
setState(() {
_pttBoundKeyLabel = binding.keyLabel;
_pttBoundInputClass = binding.inputClass;
});
}
} catch (_) {
// No persisted binding or storage not yet wired; not an error.
}
}
Future<void> _reloadBookmarks() async {
try {
await wireStorage();
final list = await rust.listBookmarks();
if (!mounted) return;
setState(() => _bookmarks = list);
} catch (_) {
// Bookmark store missing on this platform — empty list is fine.
}
}
void _onEvent(rust.BridgeEvent evt) {
if (!mounted) return;
switch (evt) {
case rust.BridgeEvent_Connected():
final hadSnapshot = _snapshot != null;
setState(() {
_phase = phaseAfterConnectedEvent(_phase, hasSnapshot: hadSnapshot);
_lostReason = null;
_reconnectAttempt = null;
_reconnectDelay = null;
});
unawaited(_refreshSnapshot(recordActivity: false, reportErrors: true));
case rust.BridgeEvent_Lost(:final reason):
_recordUiDiagnostic('connection', 'lost: $reason');
setState(() {
_phase = ConnectionPhase.reconnecting;
_lostReason = reason;
_reconnectAttempt = null;
_reconnectDelay = null;
});
case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs):
_recordUiDiagnostic(
'connection',
'reconnecting attempt=$attempt delay=${delaySecs}s',
);
setState(() {
_phase = ConnectionPhase.reconnecting;
_reconnectAttempt = attempt;
_reconnectDelay = delaySecs;
});
case rust.BridgeEvent_Disconnected():
_recordUiDiagnostic('connection', 'disconnected');
setState(() {
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
});
case rust.BridgeEvent_AudioStarted():
_ensureStatsTimer();
case rust.BridgeEvent_AudioStopped():
_statsTimer?.cancel();
_statsTimer = null;
case rust.BridgeEvent_PttCapability(
:final level,
:final backendId,
:final boundInputClass,
):
setState(() {
_pttLevel = level;
_pttBackendId = backendId;
_pttBoundInputClass = boundInputClass;
});
if (backendId != 'focused') {
_releaseFocusedPttIfHeld();
}
case rust.BridgeEvent_VoiceState(
:final inChannel,
:final transmitMode,
:final mute,
:final releaseTailMs,
:final currentChannelId,
:final pendingTargetChannelId,
:final canJoin,
):
setState(() {
_voiceStateInitialized = true;
_inChannel = inChannel;
_transmitMode = transmitMode;
_hardMute = mute;
if (!mute) _hardMuteByPermission = false;
_releaseTailMs = releaseTailMs;
_currentVoiceChannelId = currentChannelId;
_pendingVoiceChannelId = pendingTargetChannelId;
_canJoinVoiceChannel = canJoin;
});
if (inChannel) {
_ensureStatsTimer();
} else {
_statsTimer?.cancel();
_statsTimer = null;
_releaseFocusedPttIfHeld();
}
if (transmitMode != rust.BridgeTransmitMode.ptt) {
_releaseFocusedPttIfHeld();
}
case rust.BridgeEvent_InterruptionState(
:final began,
:final shouldResume,
):
// Surface iOS audio interruption to the user (SDD-101).
// Use unawaited to stay inside the sync _onEvent stream
// without blocking it.
if (!mounted) return;
unawaited(() async {
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
if (began) {
_recordUiDiagnostic('audio', 'interruption began');
messenger.showSnackBar(
SnackBar(
content: Text(AppL10n.of(context).iosAudioInterrupted),
duration: Duration(seconds: 3),
backgroundColor: Colors.orange,
),
);
} else if (shouldResume) {
_recordUiDiagnostic('audio', 'interruption ended, resuming');
messenger.showSnackBar(
SnackBar(
content: Text(AppL10n.of(context).iosAudioResuming),
duration: Duration(seconds: 2),
backgroundColor: Colors.green,
),
);
}
}());
// SDD-106 §5/§6 / SRS-209: defensive observer of the
// authoritative Rust-side permission stream. The transmit
// clamp is already applied inside `chanora_bridge` before
// this event is broadcast. When permission later becomes
// granted, release only the permission-owned hard-mute; do
// not touch server-side input mute, which may be user-owned.
case rust.BridgeEvent_PermissionState(:final permission, :final state):
debugPrint(
'bridge permission_state: permission=$permission state=$state',
);
if (permission == 'android.permission.RECORD_AUDIO' &&
state == rust.PermissionStateKind.granted) {
unawaited(_clearPermissionHardMute());
}
case rust.BridgeEvent_ChatMessage(
:final senderId,
:final senderName,
:final message,
:final target,
):
// Skip echo of self-sent messages (already added locally).
if (senderId == _snapshot?.ownClientId) return;
final isPoke = target is rust.BridgeMessageTarget_Poke;
final receivedAt = DateTime.now();
setState(() {
_appendChatEntryUnlocked(
ChatEntry(
senderId: senderId,
senderName: senderName,
message: message,
target: target,
isSelf: senderId == _snapshot?.ownClientId,
timestamp: receivedAt,
),
);
});
if (isPoke) {
_showPokeSnackBar(
senderName: senderName,
message: message,
receivedAt: receivedAt,
);
return;
}
if (!_chatOpen && _chatUnread > 0) {
if (!mounted) return;
unawaited(() async {
if (!mounted) return;
_showChatMessageSnackBar(
senderName: senderName,
message: message,
target: target,
);
}());
}
case rust.BridgeEvent_ServerActivity(:final message):
setState(() {
_appendChatEntryUnlocked(
ChatEntry(
senderId: BigInt.zero,
senderName: 'Server',
message: message,
target: const rust.BridgeMessageTarget.server(),
timestamp: DateTime.now(),
countsTowardUnread: false,
),
);
});
case rust.BridgeEvent_AudioRouteChanged():
break;
case rust.BridgeEvent_ClientMoved(:final clientId, :final newChannelId):
_applyClientDelta((c) => c.id == clientId, (c) {
final updated = rust.BridgeClient(
id: c.id,
channel: newChannelId,
name: c.name,
inputMuted: c.inputMuted,
outputMuted: c.outputMuted,
isSpeaking: c.isSpeaking,
isServerQuery: c.isServerQuery,
talkPower: c.talkPower,
talkPowerGranted: c.talkPowerGranted,
);
return updated;
});
case rust.BridgeEvent_ClientJoined(
:final clientId,
:final channelId,
:final name,
:final inputMuted,
:final outputMuted,
:final isServerQuery,
:final talkPower,
:final talkPowerGranted,
):
if (!isServerQuery) {
_applyClientAdd(
rust.BridgeClient(
id: clientId,
channel: channelId,
name: name,
inputMuted: inputMuted,
outputMuted: outputMuted,
isSpeaking: false,
isServerQuery: isServerQuery,
talkPower: talkPower,
talkPowerGranted: talkPowerGranted,
),
);
}
case rust.BridgeEvent_ClientLeft(:final clientId):
_applyClientRemove(clientId);
case rust.BridgeEvent_ClientUpdated(
:final clientId,
:final inputMuted,
:final outputMuted,
:final isServerQuery,
:final talkPower,
:final talkPowerGranted,
):
_applyClientDelta((c) => c.id == clientId, (c) {
final updated = rust.BridgeClient(
id: c.id,
channel: c.channel,
name: c.name,
inputMuted: inputMuted,
outputMuted: outputMuted,
isSpeaking: c.isSpeaking,
isServerQuery: isServerQuery,
talkPower: talkPower,
talkPowerGranted: talkPowerGranted,
);
return updated;
});
case rust.BridgeEvent_ChannelAdded(
:final id,
:final parent,
:final name,
:final order,
:final hasPassword,
:final neededTalkPower,
):
_applyChannelAdd(
rust.BridgeChannel(
id: id,
parent: parent,
name: name,
order: order,
hasPassword: hasPassword,
neededTalkPower: neededTalkPower,
),
);
case rust.BridgeEvent_ChannelRemoved(:final id):
_applyChannelRemove(id);
case rust.BridgeEvent_ChannelUpdated(
:final id,
:final name,
:final hasPassword,
:final neededTalkPower,
):
_applyChannelDelta((ch) => ch.id == id, (ch) {
return rust.BridgeChannel(
id: ch.id,
parent: ch.parent,
name: name,
order: ch.order,
hasPassword: hasPassword,
neededTalkPower: neededTalkPower,
);
});
}
}
void _stopStatsPolling() {
_statsTimer?.cancel();
_statsTimer = null;
}
void _startStatsPollingIfConnected() {
if (_serverReachable) _ensureStatsTimer();
}
DateTime? _lastSpeakingRefresh;
static const _speakingRefreshInterval = Duration(milliseconds: 750);
void _ensureStatsTimer() {
if (_statsTimer != null) return;
_lastSpeakingRefresh = null;
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
try {
final s = await rust.audioStats();
if (!mounted) return;
setState(() => _audioStats = s);
} catch (_) {}
final now = DateTime.now();
if (_inChannel &&
(_lastSpeakingRefresh == null ||
now.difference(_lastSpeakingRefresh!) >=
_speakingRefreshInterval)) {
_lastSpeakingRefresh = now;
unawaited(_refreshSnapshot(recordActivity: false, reportErrors: false));
}
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final stateName = state.name;
debugPrint('chanora: app $stateName');
rust.recordLifecycleEvent(state: stateName);
switch (state) {
case AppLifecycleState.paused:
_stopStatsPolling();
case AppLifecycleState.resumed:
_startStatsPollingIfConnected();
case AppLifecycleState.hidden:
case AppLifecycleState.inactive:
break;
case AppLifecycleState.detached:
_stopStatsPolling();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey);
_eventsSub?.cancel();
_inputLevelSub?.cancel();
_statsTimer?.cancel();
_snapshotRefreshInFlight = false;
_snapshotRefreshQueued = false;
_snapshotRefreshQueuedRecordActivity = false;
_snapshotRefreshQueuedReportErrors = false;
_hostCtl.removeListener(_onHostEdited);
_prefetch.dispose();
_hostCtl.dispose();
_nickCtl.dispose();
_passwordCtl.dispose();
_chatFeedRevision.dispose();
_pokeSnackBarPokes.dispose();
_androidPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged,
);
_iosPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged,
);
// SRS-198 / SRS-297: detach macOS permission listeners.
_macOSPermissions.pttCapabilityState.removeListener(
_onMacOSPttCapabilityChanged,
);
// SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
// late invokeMethod from the platform side cannot land on this
// disposed state.
_androidPermissions.stop();
_iosPermissions.stop();
_macOSPermissions.stop();
super.dispose();
}
Future<void> _onConnect({
String? host,
String? nickname,
String? password,
}) async {
final connectEpoch = ++_connectionEpoch;
setState(() {
_phase = ConnectionPhase.connecting;
_snapshot = null;
_chatMessages.clear();
});
_releaseFocusedPttIfHeld();
try {
final snap = await rust.connect(
host: (host ?? _hostCtl.text).trim(),
nickname: (nickname ?? _nickCtl.text).trim(),
password: password ?? _passwordCtl.text,
);
if (!mounted) return;
final connectedHost = host ?? _hostCtl.text.trim();
final connectedNickname = nickname ?? _nickCtl.text.trim();
setState(() {
_phase = ConnectionPhase.synchronizing;
_applySnapshot(snap);
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted ||
_connectionEpoch != connectEpoch ||
!_serverReachable ||
_snapshot?.serverName != snap.serverName) {
return;
}
unawaited(
_runPostConnectSideEffects(
snap: snap,
host: connectedHost,
nickname: connectedNickname,
),
);
});
} catch (e) {
if (!mounted) return;
final errorStr = e.toString();
if (errorStr.contains('PermissionDenied') ||
errorStr.contains('Operation not permitted')) {
_showPermissionDeniedDialog(errorStr);
}
setState(() {
_phase = ConnectionPhase.idle;
});
_showUiErrorSnackBar(area: 'connect', error: e);
}
}
Future<void> _runPostConnectSideEffects({
required rust.BridgeSnapshot snap,
required String host,
required String nickname,
}) async {
unawaited(_saveUiSettings(host: host, nickname: nickname));
try {
await FlutterForegroundTask.startService(
notificationTitle: 'Chanora',
notificationText: 'Connected to ${snap.serverName}',
notificationButtons: const [
NotificationButton(id: 'disconnect', text: 'Disconnect'),
],
);
} catch (_) {}
}
void _showPermissionDeniedDialog(String rawError) {
final l10n = AppL10n.of(context);
final isNetwork = rawError.contains('9987') || rawError.contains('connect');
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied,
),
content: Text(
isNetwork
? l10n.networkPermissionBody
: l10n.microphonePermissionBody,
),
actions: [
if (Platform.isIOS && !isNetwork)
TextButton(
onPressed: () {
Navigator.pop(ctx);
unawaited(_openIosAppSettings());
},
child: Text(l10n.networkPermissionOpenSettings),
),
if (Platform.isMacOS)
TextButton(
onPressed: () {
Navigator.pop(ctx);
try {
Process.run('open', [
'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
]);
} catch (_) {}
},
child: Text(l10n.networkPermissionOpenSettings),
),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
);
}
Future<void> _openIosAppSettings() async {
try {
await _iosPlatformChannel.invokeMethod<bool>('openAppSettings');
} catch (_) {
// Best-effort affordance only; if iOS refuses the URL, the
// dialog still explained the missing microphone permission.
}
}
Future<void> _setPtt(bool active, {bool reportError = true}) async {
if (active) {
try {
HapticFeedback.lightImpact();
} catch (_) {}
}
try {
await rust.setPtt(active: active);
} catch (e) {
if (!mounted || !reportError) return;
_showUiError('ptt', e);
}
}
Future<void> _toggleOutputMute() async {
final next = !_outputMuted;
setState(() {
_outputMuted = next;
});
try {
await rust.setOutputMuted(muted: next);
} catch (e) {
if (!mounted) return;
setState(() {
_outputMuted = !next;
});
_showUiError('output mute', e);
}
}
Future<void> _onJoinChannel(
rust.BridgeChannel ch, {
bool askForPassword = false,
}) async {
if (!_canJoinVoiceChannel) return;
if (_pendingVoiceChannelId != null) return;
if (ch.id == _currentVoiceChannelId) return;
final l10n = AppL10n.of(context);
String? password;
if (askForPassword) {
password = await _askChannelPassword(l10n);
if (password == null) return; // cancelled
}
setState(() => _pendingVoiceChannelId = ch.id);
try {
// SDD-106 §1, §6 + SRS-209: Android runtime permission gate.
// Request RECORD_AUDIO at or before voice_join. On denial or
// permanent denial we still proceed (listen-only is a
// first-class mode per SDD-106) but clamp local transmit via
// setHardMute so the audio engine never opens the capture
// stream as a sender. On non-Android the service short-circuits
// to granted and this branch is a no-op.
//
// Trace: SDD-106 §1 (request timing), §2 (listen-only on denial),
// §3 (path to settings on permanent denial), §6
// (TransmitModeSelector clamp); SRS-209.
final permState = Platform.isAndroid
? await _androidPermissions.ensureRecordAudio()
: Platform.isIOS
? await _iosPermissions.ensureRecordAudio()
: AndroidRecordAudioPermissionState.granted;
if (permState != AndroidRecordAudioPermissionState.granted) {
// Listen-only: clamp hard-mute. The permission_state_banner
// surfaces the path-to-grant; the user can re-attempt at any
// time via the Grant / Open Settings action.
try {
await rust.setHardMute(muted: true);
if (mounted) {
setState(() {
_hardMute = true;
_hardMuteByPermission = true;
});
}
} catch (_) {
// Best-effort clamp; if the bridge isn't ready we still
// proceed. The capture path also self-clamps on Android
// when RECORD_AUDIO is not granted (SDD-106 §6 Rust-side,
// via BridgeEvent::PermissionState → TransmitModeSelector
// AtomicU8 clamp); this Dart setHardMute is the
// defence-in-depth path.
}
} else if (_hardMuteByPermission) {
await rust.setHardMute(muted: false);
if (mounted) {
setState(() {
_hardMute = false;
_hardMuteByPermission = false;
});
}
}
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
if (!mounted) return;
setState(() {
_currentVoiceChannelId = ch.id;
_pendingVoiceChannelId = null;
_inChannel = true;
_canJoinVoiceChannel = true;
_voiceStateInitialized = true;
});
} catch (e) {
if (!mounted) return;
if (_isAlreadyInChannel(e)) {
setState(() {
_currentVoiceChannelId = ch.id;
_pendingVoiceChannelId = null;
_inChannel = true;
});
return;
}
if (_isFlooding(e)) {
setState(() {
_pendingVoiceChannelId = null;
});
_showUiErrorSnackBar(
area: 'join channel',
error: e,
displayMessage: l10n.channelJoinFailedFlooding,
);
return;
}
final message = channelJoinErrorMessage(l10n, e);
setState(() {
_pendingVoiceChannelId = null;
});
_showUiErrorSnackBar(
area: 'join channel',
error: e,
displayMessage: message,
);
}
}
bool _isAlreadyInChannel(Object error) {
if (error is rust_err.BridgeError_ServerRejected) {
return error.code == 0x0302;
}
return false;
}
bool _isFlooding(Object error) {
if (error is rust_err.BridgeError_ServerRejected) {
return error.code == 0x020c;
}
return false;
}
Future<void> _onToggleHardMute() async {
// Don't allow manual unmute when talk-power-muted.
if (_hardMuteByTalkPower && _hardMute) {
return;
}
final next = !_hardMute;
final previousInputMuted = _inputMuted;
final previousHardMute = _hardMute;
final previousPermissionMute = _hardMuteByPermission;
setState(() {
_inputMuted = next;
_hardMute = next;
if (next) {
_hardMuteByPermission = false;
}
});
try {
// Hard-mute is two coordinated effects:
// * setHardMute — local TransmitGate clamp; we stop sending
// Opus frames the instant this returns.
// * setInputMuted — server-side ClientMuted flag so other
// clients see the mic-off icon next to our name and the
// server stops relaying any in-flight frames.
// Sending only one of them is user-confusing; clients see
// silence but no icon, or icon but a beat of audio leaks
// through. Drive them together.
await rust.setHardMute(muted: next);
await rust.setInputMuted(muted: next);
} catch (e) {
if (!mounted) return;
setState(() {
_inputMuted = previousInputMuted;
_hardMute = previousHardMute;
_hardMuteByPermission = previousPermissionMute;
});
_showUiError('hard mute', e);
}
}
/// Touch-only PTT (iOS / iPadOS / Android). The on-screen
/// `_PttHoldButton` calls this with `true` on finger-down and
/// `false` on finger-up (or cancel). The bridge's
/// `setPtt(active:)` routes the press edge through the same
/// release-tail timer + transmit-mode selector the desktop
/// hardware-key paths use (SDD-096 / SAD-083), so the user-
/// visible behaviour is identical across platforms — only the
/// input device changes.
///
/// Errors are swallowed silently in the held=false branch
/// because the timer's `key_up` is idempotent; a failed send
/// would still let the tail expire naturally. Errors on
/// held=true surface in the UI banner so the user knows the
/// mic didn't open.
Future<void> _onOnscreenPttHeldChanged(bool held) async {
try {
await rust.setPtt(active: held);
} catch (e) {
if (held) {
if (!mounted) return;
_showUiError('onscreen ptt', e);
}
}
}
Future<rust.BridgeAudioProcessingConfig> _loadAudioProcessingConfig() async {
try {
return await rust.getAudioProcessingConfig();
} catch (_) {
return defaultAudioProcessingConfig();
}
}
void _appendChatEntryUnlocked(ChatEntry entry) {
_chatMessages.add(entry);
if (_chatMessages.length > 200) {
_chatMessages.removeRange(0, _chatMessages.length - 200);
}
_notifyChatFeedChanged();
if (!_chatOpen && !entry.isPoke && entry.countsTowardUnread) {
_chatUnread++;
}
}
void _notifyChatFeedChanged() {
_chatFeedRevision.value++;
}
/// Narrow-mode voice controls modal sheet (Plan E status chip
/// trigger). On mobile this is the **single** voice-controls
/// surface: route picker + inline mode radio + inline release-tail
/// slider + level meter + stats + audio processing + (desktop-only)
/// capability badge. Zero navigation depth — no nested dialog.
Future<void> _onOpenVoiceDetailsSheet() async {
final audioConfig = await _loadAudioProcessingConfig();
if (!mounted) return;
await showVoiceDetailsSheet(
context,
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
isTouchOnly: isTouchOnlyPttHost,
initialAudioConfig: audioConfig,
onModeChanged: (mode) async {
try {
await rust.setTransmitMode(mode: mode);
if (!mounted) return;
setState(() => _transmitMode = mode);
} catch (e) {
if (!mounted) return;
_showUiError('transmit mode', e);
}
},
onReleaseTailChanged: (ms) async {
try {
await rust.setReleaseTailMs(ms: ms);
if (!mounted) return;
setState(() => _releaseTailMs = ms);
} catch (e) {
if (!mounted) return;
_showUiError('release tail', e);
}
},
onAudioConfigChanged: (config) async {
try {
await rust.setAudioProcessingConfig(config: config);
} catch (e) {
if (!mounted) return;
_showUiError('audio processing config', e);
}
},
);
}
Future<void> _onOpenVoiceSettings() async {
final audioConfig = await _loadAudioProcessingConfig();
if (!mounted) return;
final result = await showDialog<VoiceSettingsResult>(
context: context,
builder: (ctx) => VoiceSettingsDialog(
initialMode: _transmitMode,
initialReleaseTailMs: _releaseTailMs,
initialAudioConfig: audioConfig,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
),
);
if (result == null) return;
try {
await rust.setTransmitMode(mode: result.mode);
await rust.setReleaseTailMs(ms: result.releaseTailMs);
await rust.setAudioProcessingConfig(config: result.audioConfig);
} catch (e) {
if (!mounted) return;
_showUiError('voice settings', e);
}
if (result.bindKeyRequested && mounted) {
await _onConfigurePtt(context);
if (mounted) {
unawaited(_onOpenVoiceSettings());
}
}
}
Future<String?> _askChannelPassword(AppL10n l10n) async {
// Same pattern as _onAddCurrentBookmark: route the dialog
// through a dedicated StatefulWidget so its
// TextEditingController is disposed at unmount time, not
// synchronously after `await showDialog` resumes. Inline
// dispose-after-await caused framework.dart:6268
// _dependents.isEmpty assertions on iOS \u2014 the controller was
// torn out while EditableText still depended on InheritedWidgets
// belonging to the still-popping dialog route.
return await showDialog<String>(
context: context,
builder: (ctx) => const ChannelPasswordDialog(),
);
}
Future<void> _refreshSnapshot({
required bool recordActivity,
required bool reportErrors,
}) async {
if (_snapshotRefreshInFlight) {
_snapshotRefreshQueued = true;
_snapshotRefreshQueuedRecordActivity =
_snapshotRefreshQueuedRecordActivity || recordActivity;
_snapshotRefreshQueuedReportErrors =
_snapshotRefreshQueuedReportErrors || reportErrors;
return;
}
_snapshotRefreshInFlight = true;
var nextRecordActivity = recordActivity;
var nextReportErrors = reportErrors;
try {
while (mounted) {
_snapshotRefreshQueued = false;
_snapshotRefreshQueuedRecordActivity = false;
_snapshotRefreshQueuedReportErrors = false;
final previousSnapshot = _snapshot;
try {
final snap = await rust.snapshot();
if (!mounted) return;
final activityEntries = nextRecordActivity && previousSnapshot != null
? buildServerActivityEntries(
previous: previousSnapshot,
current: snap,
)
: const <ChatEntry>[];
setState(() {
_applySnapshot(snap);
for (final entry in activityEntries) {
_appendChatEntryUnlocked(entry);
}
});
} catch (e) {
if (nextReportErrors && mounted) {
_showUiError('refresh snapshot', e);
}
}
if (!_snapshotRefreshQueued) {
break;
}
nextRecordActivity = _snapshotRefreshQueuedRecordActivity;
nextReportErrors = _snapshotRefreshQueuedReportErrors;
}
} finally {
_snapshotRefreshInFlight = false;
_snapshotRefreshQueued = false;
_snapshotRefreshQueuedRecordActivity = false;
_snapshotRefreshQueuedReportErrors = false;
}
}
Future<void> _onDisconnect() async {
_connectionEpoch++;
_releaseFocusedPttIfHeld();
if (mounted) {
setState(() {
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
});
}
unawaited(_finishDisconnect());
}
Future<void> _onConfirmDisconnect() async {
final l10n = AppL10n.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.disconnectConfirmTitle),
content: Text(l10n.disconnectConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(l10n.disconnectConfirmCancel),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(l10n.disconnectConfirmAction),
),
],
),
);
if (confirmed == true && mounted && _phase.canDisconnect) {
await _onDisconnect();
}
}
Future<void> _finishDisconnect() async {
_statsTimer?.cancel();
_statsTimer = null;
_snapshotRefreshInFlight = false;
_snapshotRefreshQueued = false;
_snapshotRefreshQueuedRecordActivity = false;
_snapshotRefreshQueuedReportErrors = false;
try {
await rust.disconnect();
} catch (_) {}
try {
await FlutterForegroundTask.stopService();
} catch (_) {}
// Reload bookmarks so auto-saved recent-server entries from the
// just-ended session appear in the connect-page bookmark list.
unawaited(_reloadBookmarks());
}
void _resetConnectionUiState({required ConnectionPhase phase}) {
_phase = phase;
_snapshot = null;
_audioStats = null;
_inputMuted = false;
_outputMuted = false;
_hardMute = false;
_hardMuteByPermission = false;
_hardMuteByTalkPower = false;
_inChannel = false;
_currentVoiceChannelId = null;
_pendingVoiceChannelId = null;
_canJoinVoiceChannel = true;
_voiceStateInitialized = false;
_lostReason = null;
_reconnectAttempt = null;
_reconnectDelay = null;
_chatMessages.clear();
_chatUnread = 0;
_chatOpen = false;
_notifyChatFeedChanged();
}
Future<void> _onOpenChat({
rust.BridgeMessageTarget? target,
String clientName = '',
}) async {
final initialSnapshot = _snapshot!;
setState(() {
_chatUnread = 0;
_chatOpen = true;
});
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatPage(
messages: _chatMessages,
snapshot: initialSnapshot,
messagesSource: () => _chatMessages,
snapshotSource: () => _snapshot ?? initialSnapshot,
refreshListenable: _chatFeedRevision,
initialTarget:
target ??
resolveInitialChatTarget(
messages: _chatMessages,
currentVoiceChannelId: _currentVoiceChannelId,
),
initialClientName: clientName,
onTs3ServerLink: _onTs3ServerLink,
),
),
);
if (mounted) setState(() => _chatOpen = false);
}
Future<void> _onOpenClientInfo(rust.BridgeClient client) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: false,
builder: (context) => FractionallySizedBox(
heightFactor: 0.86,
child: ClientInfoSheet(
clientName: client.name,
loadProfile: () => rust.clientProfile(clientId: client.id),
),
),
);
}
void _showPokeSnackBar({
required String senderName,
required String message,
required DateTime receivedAt,
}) {
_pokeSnackBarPokes.value = [
..._pokeSnackBarPokes.value,
_ReceivedPoke(
senderName: senderName,
message: message,
receivedAt: receivedAt,
),
];
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
}
void _renderPokeSnackBar() {
if (_pokeSnackBarPokes.value.isEmpty || _pokeSnackBarVisible) return;
_pokeSnackBarVisible = true;
final messenger = ScaffoldMessenger.of(context);
final controller = messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: _chatSnackBarMargin(),
duration: const Duration(days: 365),
dismissDirection: DismissDirection.none,
content: _PokeSnackBarContent(pokes: _pokeSnackBarPokes),
action: SnackBarAction(
label: AppL10n.of(context).pokeSnackBarClearAction,
onPressed: () {
_pokeSnackBarPokes.value = const [];
_pokeSnackBarVisible = false;
},
),
),
);
controller.closed.then((_) {
if (!mounted) return;
_pokeSnackBarVisible = false;
if (_pokeSnackBarPokes.value.isEmpty) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
});
}
void _showChatMessageSnackBar({
required String senderName,
required String message,
required rust.BridgeMessageTarget target,
}) {
if (_pokeSnackBarPokes.value.isNotEmpty) return;
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: _chatSnackBarMargin(),
duration: const Duration(seconds: 7),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$senderName (${_chatTargetLabel(target)})',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(message, maxLines: 3, overflow: TextOverflow.ellipsis),
],
),
action: SnackBarAction(
label: AppL10n.of(context).openAction,
onPressed: () => unawaited(
_onOpenChat(
target: target,
clientName: _chatClientNameForTarget(target, senderName),
),
),
),
),
);
}
EdgeInsetsGeometry _chatSnackBarMargin() {
const side = 16.0;
var bottom = 16.0;
final wideConnectedLayout =
MediaQuery.sizeOf(context).width >= _wideBreakpoint &&
_serverReachable &&
_snapshot != null;
if (_serverReachable && _snapshot != null && !wideConnectedLayout) {
bottom += 64; // VoiceStatusChip.
if (_inChannel && _transmitMode == rust.BridgeTransmitMode.ptt) {
bottom += 64; // 8 dp gap + default 56 dp VoicePttButton.
}
}
return EdgeInsetsDirectional.fromSTEB(side, 0, side, bottom);
}
String _chatTargetLabel(rust.BridgeMessageTarget target) {
return switch (target) {
rust.BridgeMessageTarget_Client() => 'Private Chat',
rust.BridgeMessageTarget_Poke() => 'Poke',
rust.BridgeMessageTarget_Channel() => 'Channel Chat',
rust.BridgeMessageTarget_Server() => 'Server',
};
}
String _chatClientNameForTarget(
rust.BridgeMessageTarget target,
String senderName,
) {
return switch (target) {
rust.BridgeMessageTarget_Client() ||
rust.BridgeMessageTarget_Poke() => senderName,
_ => '',
};
}
void _applyClientDelta(
bool Function(rust.BridgeClient) test,
rust.BridgeClient Function(rust.BridgeClient) update,
) {
final snap = _snapshot;
if (snap == null) return;
final clients = snap.clients.map((c) => test(c) ? update(c) : c).toList();
setState(() {
_snapshot = rust.BridgeSnapshot(
serverName: snap.serverName,
welcomeMessage: snap.welcomeMessage,
platform: snap.platform,
version: snap.version,
channels: snap.channels,
clients: clients,
ownClientId: snap.ownClientId,
);
_notifyChatFeedChanged();
});
}
void _applyClientAdd(rust.BridgeClient client) {
final snap = _snapshot;
if (snap == null) return;
setState(() {
_snapshot = rust.BridgeSnapshot(
serverName: snap.serverName,
welcomeMessage: snap.welcomeMessage,
platform: snap.platform,
version: snap.version,
channels: snap.channels,
clients: [...snap.clients, client],
ownClientId: snap.ownClientId,
);
_notifyChatFeedChanged();
});
}
void _applyClientRemove(BigInt clientId) {
final snap = _snapshot;
if (snap == null) return;
setState(() {
_snapshot = rust.BridgeSnapshot(
serverName: snap.serverName,
welcomeMessage: snap.welcomeMessage,
platform: snap.platform,
version: snap.version,
channels: snap.channels,
clients: snap.clients.where((c) => c.id != clientId).toList(),
ownClientId: snap.ownClientId,
);
_notifyChatFeedChanged();
});
}
void _applyChannelAdd(rust.BridgeChannel channel) {
final snap = _snapshot;
if (snap == null) return;
setState(() {
_snapshot = rust.BridgeSnapshot(
serverName: snap.serverName,
welcomeMessage: snap.welcomeMessage,
platform: snap.platform,
version: snap.version,
channels: [...snap.channels, channel],
clients: snap.clients,
ownClientId: snap.ownClientId,
);
_notifyChatFeedChanged();
});
}
void _applyChannelRemove(BigInt channelId) {
final snap = _snapshot;
if (snap == null) return;
setState(() {
_snapshot = rust.BridgeSnapshot(
serverName: snap.serverName,
welcomeMessage: snap.welcomeMessage,
platform: snap.platform,
version: snap.version,
channels: snap.channels.where((ch) => ch.id != channelId).toList(),
clients: snap.clients,
ownClientId: snap.ownClientId,
);
_notifyChatFeedChanged();
});
}
void _applyChannelDelta(
bool Function(rust.BridgeChannel) test,
rust.BridgeChannel Function(rust.BridgeChannel) update,
) {
final snap = _snapshot;
if (snap == null) return;
final channels = snap.channels
.map((ch) => test(ch) ? update(ch) : ch)
.toList();
setState(() {
_snapshot = rust.BridgeSnapshot(
serverName: snap.serverName,
welcomeMessage: snap.welcomeMessage,
platform: snap.platform,
version: snap.version,
channels: channels,
clients: snap.clients,
ownClientId: snap.ownClientId,
);
_notifyChatFeedChanged();
});
}
void _applySnapshot(rust.BridgeSnapshot snap) {
_snapshot = snap;
_phase = phaseAfterSnapshotApplied(_phase);
final own = ownClientSnapshotState(snap);
if (own == null) return;
_inputMuted = own.inputMuted;
_outputMuted = own.outputMuted;
if (!_voiceStateInitialized || _currentVoiceChannelId == null) {
_currentVoiceChannelId = own.channelId;
_pendingVoiceChannelId = null;
_inChannel = true;
_canJoinVoiceChannel = true;
_voiceStateInitialized = true;
} else if (_pendingVoiceChannelId == null) {
_currentVoiceChannelId = own.channelId;
}
_notifyChatFeedChanged();
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
final talkPowerEpoch = _connectionEpoch;
_hardMuteByTalkPower = true;
_hardMute = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted ||
_connectionEpoch != talkPowerEpoch ||
!_serverReachable ||
!_hardMuteByTalkPower) {
return;
}
unawaited(rust.setHardMute(muted: true));
unawaited(rust.setInputMuted(muted: true));
});
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
final talkPowerEpoch = _connectionEpoch;
_hardMuteByTalkPower = false;
if (!_hardMuteByPermission) {
_hardMute = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted ||
_connectionEpoch != talkPowerEpoch ||
!_serverReachable ||
_hardMuteByTalkPower ||
_hardMuteByPermission) {
return;
}
unawaited(rust.setHardMute(muted: false));
unawaited(rust.setInputMuted(muted: false));
});
}
}
}
OwnClientSnapshotState? get _ownClientState {
final snap = _snapshot;
return snap == null ? null : ownClientSnapshotState(snap);
}
String _diagnosticsText() {
final rustText = rust.exportDiagnostics();
final uiText = _uiDiagnostics.isEmpty
? 'UI diagnostics: none'
: ['UI diagnostics:', ..._uiDiagnostics].join('\n');
return '$uiText\n\nRust diagnostics:\n$rustText';
}
Future<void> _onShowDiagnostics() async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) =>
_LiveDiagnosticsDialog(diagnosticsTextBuilder: _diagnosticsText),
);
}
Future<void> _onConfigurePtt(BuildContext context) async {
// On the Linux GNOME-Wayland portal backend, the portal hosts
// its own system-managed binding dialog (gen2 v0.9.3 / Q3a).
// Skip the in-app capture dialog entirely on that backend and
// delegate to the portal via `setPttBinding` with a sentinel
// platform_key. Show a SnackBar so the user isn't surprised
// when their compositor opens a separate dialog.
final l10n = AppL10n.of(context);
if (!mounted) return;
if (_pttBackendId == 'gnome-wayland-portal') {
try {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(content: Text(l10n.pttConfigurePortalRedirect)),
);
await rust.setPttBinding(
inputClass: rust.BridgePttInputClass.keyboard,
platformKey: 'portal',
);
} catch (e) {
if (!mounted) return;
_showUiErrorSnackBar(area: 'ptt portal binding', error: e);
}
return;
}
// Other backends: open the in-app focus-scoped capture
// dialog. The bridge carries only the coarse input class and
// an opaque platform-key string; the actual key value never
// appears in any log record (DEC-027 / SRS-202).
final binding = await showDialog<CapturedBinding>(
context: context,
builder: (ctx) => const PttBindingCaptureDialog(),
);
if (binding == null) return;
try {
await rust.setPttBinding(
inputClass: binding.inputClass,
platformKey: binding.platformKey,
);
// Cache the captured label so the badge can surface "Key:
// Space" / "Key: Mouse4" next to the capability descriptor.
// The bridge holds the authoritative binding; this is only
// for display continuity until the next app restart.
if (mounted) {
setState(() {
_pttBoundKeyLabel = binding.platformKey;
});
}
} catch (e) {
if (!mounted) return;
// Surface the failure as a snackbar so the user sees that
// their binding did not stick.
_showUiErrorSnackBar(area: 'ptt binding', error: e);
}
}
Future<void> _onShowAbout(BuildContext context) async {
// DEC-018 / DEC-019 / DEC-020 surface: public name, non-
// affiliation statement, dual-license declaration. The Flutter
// showAboutDialog widget is intentionally bare so the legal
// text comes from us, not a framework default.
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.aboutAction),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.appTitle, style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
l10n.aboutVersion(_kAppVersion),
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 4),
Text(l10n.aboutAuthor, style: theme.textTheme.bodySmall),
const SizedBox(height: 16),
Text(l10n.aboutNonAffiliation, style: theme.textTheme.bodyMedium),
const SizedBox(height: 12),
Text(l10n.aboutLicenseHeading, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(l10n.aboutLicenseBody, style: theme.textTheme.bodySmall),
const SizedBox(height: 12),
Text(
l10n.aboutThirdPartyHeading,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(l10n.aboutThirdPartyBody, style: theme.textTheme.bodySmall),
],
),
),
actions: [
TextButton(
onPressed: () async {
Navigator.of(ctx).pop();
await Future<void>.delayed(Duration.zero);
if (!mounted) return;
await _onShowDiagnostics();
},
child: Text(l10n.diagnosticsAction),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.closeAction),
),
],
),
);
}
Future<void> _onAddCurrentBookmark() async {
// Use the dialog's own context for AppL10n.of(...) inside its
// builder. Capturing the outer _HomePageState context's l10n in
// a closure and reading it inside the dialog's widget tree
// caused the dialog's TextField to depend on InheritedElements
// (Localizations / _LocalizationsScope) that belong to the
// outer route. When the dialog popped, the framework
// deactivated the dialog route's elements first while those
// outer-route InheritedElements were still alive but had
// dependents from the disposed dialog tree \u2014 producing the
// assertion 'package:flutter/src/widgets/framework.dart line
// 6268 _dependents.isEmpty is not true'.
//
// Also: dispose the TextEditingController via the dialog's own
// StatefulBuilder lifecycle instead of an inline dispose() right
// after showDialog returns. The inline dispose runs synchronously
// before the dialog route is fully torn down (the route pop
// animation is still mid-flight on iOS), and tearing the
// controller out from under EditableText while it has a live
// InheritedWidget dependency was the second trigger for the
// same assertion.
final initial = _hostCtl.text.trim();
final name = await showDialog<String>(
context: context,
builder: (ctx) => BookmarkNameDialog(initialName: initial),
);
if (name == null || name.trim().isEmpty) return;
if (!mounted) return;
try {
await wireStorage();
await rust.addBookmark(
b: rust.BridgeBookmark(
id: 0,
displayName: name.trim(),
host: _hostCtl.text.trim(),
nickname: _nickCtl.text.trim(),
password: _passwordCtl.text,
),
);
await _reloadBookmarks();
} catch (e) {
if (!mounted) return;
_showUiError('add bookmark', e);
}
}
Future<void> _onDeleteBookmark(rust.BridgeBookmark b) async {
try {
await wireStorage();
await rust.deleteBookmark(id: b.id);
await _reloadBookmarks();
} catch (e) {
if (!mounted) return;
_showUiError('delete bookmark', e);
}
}
Future<void> _onUseBookmark(rust.BridgeBookmark b) async {
_hostCtl.text = b.host;
_nickCtl.text = b.nickname;
_passwordCtl.text = b.password;
await _onConnect(host: b.host, nickname: b.nickname, password: b.password);
}
Future<void> _onTs3ServerLink(Ts3ServerLink link) async {
final host = link.hostWithPort.trim();
if (host.isEmpty) return;
final nickname = link.nickname ?? _nickCtl.text.trim();
final password = link.password ?? '';
final bookmarkName = link.addBookmark?.trim();
_hostCtl.text = host;
if (link.nickname != null) _nickCtl.text = link.nickname!;
if (link.password != null) _passwordCtl.text = link.password!;
await _saveUiSettings(host: host, nickname: _nickCtl.text.trim());
if (bookmarkName == null || bookmarkName.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Loaded TeamSpeak server link for $host')),
);
return;
}
try {
await wireStorage();
await rust.addBookmark(
b: rust.BridgeBookmark(
id: 0,
displayName: bookmarkName,
host: host,
nickname: nickname,
password: password,
),
);
await _reloadBookmarks();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Added bookmark "$bookmarkName"')));
} catch (e) {
if (!mounted) return;
_showUiError('teamspeak link bookmark', e);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final headerActions = [
IconButton(
tooltip: l10n.aboutAction,
icon: const Icon(Icons.info_outline),
onPressed: () => _onShowAbout(context),
),
ChanoraThemeModeMenu(
themeMode: widget.themeMode,
onThemeModeChanged: widget.onThemeModeChanged,
),
if (_phase.canOpenChatWithSnapshot(hasSnapshot: _snapshot != null)) ...[
Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
child: Badge(
isLabelVisible: _chatUnread > 0,
label: Text(_chatUnread.toString()),
child: IconButton(
tooltip: l10n.chatAction,
icon: const Icon(Icons.chat_bubble_outline),
onPressed: _onOpenChat,
),
),
),
],
];
const headerTitle = SizedBox.shrink();
final appBarTitle = _serverReachable
? Text(
_snapshot?.serverName ?? l10n.appTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
)
: headerTitle;
final compactIdleChrome =
!_serverReachable && MediaQuery.sizeOf(context).width < _wideBreakpoint;
final compactIdleHeader = Row(
children: [
Expanded(
child: Text(
l10n.appTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
...headerActions,
],
);
final connTokens = _phase.tokens(theme.colorScheme);
final statusText = connectionStatusText(
phase: _phase,
l10n: l10n,
serverName: _snapshot?.serverName,
lostReason: _lostReason,
reconnectAttempt: _reconnectAttempt,
reconnectDelay: _reconnectDelay,
);
final awaitingServerSnapshot = _phase.shouldShowSnapshotLoading(
hasSnapshot: _snapshot != null,
);
final bodyContent = LayoutBuilder(
builder: (ctx, bodyConstraints) {
final isWideSnapshot =
bodyConstraints.maxWidth >= _wideBreakpoint &&
_serverReachable &&
_snapshot != null;
final banner = Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
l10n.homeNotProductionReadyBanner,
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
if (!_serverReachable)
Row(
children: [
Icon(connTokens.icon, size: 18, color: connTokens.color),
const SizedBox(width: 6),
Expanded(
child: Text(
statusText,
softWrap: true,
style: theme.textTheme.titleMedium,
),
),
],
),
if (_lostReason != null || _reconnectAttempt != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color:
connTokens.background ?? theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onErrorContainer,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
_reconnectAttempt != null
? l10n.statusReconnecting(
_reconnectAttempt!,
_reconnectDelay ?? 0,
)
: l10n.statusConnectionLost(_lostReason ?? ''),
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
),
],
const SizedBox(height: 12),
if (_phase == ConnectionPhase.idle ||
_phase == ConnectionPhase.disconnected) ...[
Expanded(
child: AnimatedPadding(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
),
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ConnectForm(
hostCtl: _hostCtl,
nickCtl: _nickCtl,
passwordCtl: _passwordCtl,
onConnect: () => _onConnect(),
onAddBookmark: _onAddCurrentBookmark,
),
const SizedBox(height: 16),
BookmarkList(
bookmarks: _bookmarks,
onConnect: _onUseBookmark,
onDelete: _onDeleteBookmark,
),
],
),
),
),
),
] else if (_phase == ConnectionPhase.connecting ||
awaitingServerSnapshot) ...[
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
statusText,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
],
),
),
),
),
] else if (_serverReachable && _snapshot != null) ...[
Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) {
final voiceBar = VoiceBar(
inChannel: _inChannel,
transmitMode: _transmitMode,
hardMute: _hardMute,
outputMuted: _outputMuted,
talkPowerBlocked: _hardMuteByTalkPower,
releaseTailMs: _releaseTailMs,
channelName: snapshotChannelName(
_snapshot,
_currentVoiceChannelId,
),
audioStats: _audioStats,
inputLevel: _inputLevel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
pttBoundKeyLabel: _pttBoundKeyLabel,
onConfigure: _onOpenVoiceSettings,
onPttHeldChanged: _onOnscreenPttHeldChanged,
);
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
// banner. Self-hides on granted / unknown.
final permissionBanner =
PermissionStateBanner.fromCallbacks(
recordAudioState: _activeRecordAudioState,
ensureRecordAudio: _ensureActiveRecordAudio,
openAppSettings: _openActivePermissionSettings,
);
final snapshotView = SnapshotView(
snapshot: _snapshot!,
audioStats: _audioStats,
currentVoiceChannelId: _currentVoiceChannelId,
pendingVoiceChannelId: _pendingVoiceChannelId,
localInputMuted: _inputMuted || _hardMute,
localOutputMuted: _outputMuted,
hasJoinPending: _pendingVoiceChannelId != null,
canJoinVoiceChannel: _canJoinVoiceChannel,
onJoinChannel: (ch) => _onJoinChannel(ch),
onJoinChannelWithPassword: (ch) =>
_onJoinChannel(ch, askForPassword: true),
enableClientLongPressMenu: isTouchOnlyPttHost,
onOpenClientInfo: (client) =>
unawaited(_onOpenClientInfo(client)),
onOpenClientChat: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.client(client.id),
clientName: client.name,
),
),
onOpenClientPoke: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.poke(client.id),
clientName: client.name,
),
),
onTs3ServerLink: _onTs3ServerLink,
);
final ownClientState = _ownClientState;
const voiceBarWidthWide = 320.0;
if (constraints.maxWidth >= _wideBreakpoint) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: voiceBarWidthWide,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
banner,
const SizedBox(height: 12),
permissionBanner,
voiceBar,
],
),
),
const SizedBox(width: 12),
Expanded(child: snapshotView),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: snapshotView),
const SizedBox(height: 8),
permissionBanner,
VoiceStatusChip(
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
audioStats: _audioStats,
isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute,
outputMuted: _outputMuted,
hardMuteByTalkPower: _hardMuteByTalkPower,
talkPower: ownClientState?.talkPower,
neededTalkPower: ownClientState?.neededTalkPower,
talkPowerGranted: ownClientState?.talkPowerGranted,
onTap: () => _onOpenVoiceDetailsSheet(),
onToggleInputMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
),
if (_inChannel &&
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
const SizedBox(height: 8),
VoicePttButton(
active: _audioStats?.pttActive ?? false,
onHeldChanged: _onOnscreenPttHeldChanged,
),
],
],
);
},
),
),
],
],
);
},
);
if (_isMacOS) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.only(top: _macOSTrafficLightPad),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(child: headerTitle),
...headerActions,
],
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: bodyContent,
),
),
],
),
),
);
}
return ChanoraMobileScaffold(
compactIdleChrome: compactIdleChrome,
canDisconnect: _phase.canDisconnect,
title: appBarTitle,
actions: headerActions,
onDisconnect: _onConfirmDisconnect,
compactHeader: compactIdleHeader,
disconnectTooltip: l10n.disconnectAction,
body: bodyContent,
);
}
}
class _LiveDiagnosticsDialog extends StatefulWidget {
const _LiveDiagnosticsDialog({required this.diagnosticsTextBuilder});
final String Function() diagnosticsTextBuilder;
@override
State<_LiveDiagnosticsDialog> createState() => _LiveDiagnosticsDialogState();
}
class _LiveDiagnosticsDialogState extends State<_LiveDiagnosticsDialog> {
static const _refreshInterval = Duration(seconds: 1);
Timer? _refreshTimer;
String _text = '';
@override
void initState() {
super.initState();
_refresh();
_refreshTimer = Timer.periodic(_refreshInterval, (_) => _refresh());
}
@override
void dispose() {
_refreshTimer?.cancel();
super.dispose();
}
void _refresh() {
final next = widget.diagnosticsTextBuilder();
if (!mounted || next == _text) return;
setState(() => _text = next);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final size = MediaQuery.sizeOf(context);
return AlertDialog(
title: Row(
children: [
Expanded(child: Text(l10n.diagnosticsAction)),
const SizedBox(width: 12),
Tooltip(
message: l10n.diagnosticsLiveUpdating,
child: Icon(
Icons.sync,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
content: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 720,
maxHeight: size.height * 0.65,
),
child: SingleChildScrollView(
child: SelectableText(
_text,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
),
),
actions: [
TextButton(
onPressed: () async {
await SharePlus.instance.share(ShareParams(text: _text));
},
child: Text(l10n.shareAction),
),
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: _text));
if (!context.mounted) return;
Navigator.of(context).pop();
},
child: Text(l10n.copyAction),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
],
);
}
}
class _PokeSnackBarContent extends StatelessWidget {
const _PokeSnackBarContent({required this.pokes});
final ValueListenable<List<_ReceivedPoke>> pokes;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<List<_ReceivedPoke>>(
valueListenable: pokes,
builder: (context, entries, _) {
final l10n = AppL10n.of(context);
final visible = entries.length <= 3
? entries
: entries.sublist(entries.length - 3);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entries.length > 3)
Padding(
padding: const EdgeInsetsDirectional.only(bottom: 2),
child: Text(
l10n.pokeSnackBarMoreIndicator,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
for (final poke in visible) _PokeSnackBarRow(poke: poke),
],
);
},
);
}
}
class _PokeSnackBarRow extends StatelessWidget {
const _PokeSnackBarRow({required this.poke});
final _ReceivedPoke poke;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final message = poke.message.trim();
final text = message.isEmpty
? l10n.pokeSnackBarIncomingNoMessage(poke.senderName)
: l10n.pokeSnackBarIncomingWithMessage(poke.senderName, message);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 12),
Text(
pokeSnackBarTimeLabel(poke.receivedAt),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onInverseSurface.withValues(alpha: 0.72),
),
),
],
),
);
}
}
String pokeSnackBarTimeLabel(DateTime timestamp) {
String two(int value) => value.toString().padLeft(2, '0');
return '${two(timestamp.hour)}:${two(timestamp.minute)}';
}