Files
chanora/apps/chanora_flutter/lib/main.dart
T

2047 lines
68 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/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 'widgets/permission_state_banner.dart';
import 'widgets/audio_processing_config_state.dart';
import 'widgets/chat_views.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();
_kAppVersion = await resolveAppVersion();
unawaited(wireStorage());
unawaited(wireConnectivity());
wireAudioLifecycle();
await configureBundledVadModels();
runApp(const ChanoraApp());
}
class ChanoraApp extends StatelessWidget {
const ChanoraApp({super.key});
@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,
),
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: const _BetaHome(),
),
);
}
}
class _BetaHome extends StatefulWidget {
const _BetaHome();
@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;
String? _error;
rust.BridgeAudioStats? _audioStats;
Timer? _statsTimer;
int _statsTick = 0;
bool _voiceStatusRefreshInFlight = false;
StreamSubscription<rust.BridgeEvent>? _eventsSub;
// 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;
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 = [];
int _chatUnread = 0;
bool _chatOpen = false;
bool _showPokeDialogs = true;
final ValueNotifier<List<_ReceivedPoke>> _pokeDialogPokes = ValueNotifier(
const [],
);
bool _pokeDialogShowing = false;
IconData? _audioRoute;
// 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();
final UiPreferencesService _uiPreferences = const UiPreferencesService();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
HardwareKeyboard.instance.addHandler(_handleFocusedPttKey);
_eventsSub = rust.eventsStream().listen(_onEvent);
// 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,
);
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 (mounted) {
setState(() => _showPokeDialogs = settings.showPokeDialogs);
} else {
_showPokeDialogs = settings.showPokeDialogs;
}
} catch (_) {}
}
Future<void> _saveUiSettings({
String? host,
String? nickname,
bool? showPokeDialogs,
}) async {
try {
await _uiPreferences.saveSettings(
host: host,
nickname: nickname,
showPokeDialogs: showPokeDialogs,
);
} 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: const Text('Microphone Access'),
content: const Text(
'Chanora needs microphone access to transmit your voice in '
'TeamSpeak channels. Without this permission, you can only '
'listen to others.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Not now'),
),
FilledButton(
onPressed: () {
Navigator.pop(ctx);
_androidPermissions.ensureRecordAudio();
},
child: const Text('Allow'),
),
],
),
);
await _uiPreferences.markPermissionsExplained();
} else {
await _androidPermissions.ensureRecordAudio();
}
}
} 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());
}
}
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;
setState(() => _error = e.toString());
} finally {
_permissionHardMuteClearInFlight = false;
}
}
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 {
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():
setState(() {
_phase = ConnectionPhase.synchronizing;
_lostReason = null;
_reconnectAttempt = null;
_reconnectDelay = null;
});
case rust.BridgeEvent_Lost(:final reason):
setState(() {
_phase = ConnectionPhase.reconnecting;
_lostReason = reason;
_reconnectAttempt = null;
_reconnectDelay = null;
});
case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs):
setState(() {
_phase = ConnectionPhase.reconnecting;
_reconnectAttempt = attempt;
_reconnectDelay = delaySecs;
});
case rust.BridgeEvent_Disconnected():
setState(() {
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
});
case rust.BridgeEvent_AudioStarted():
_ensureStatsTimer();
case rust.BridgeEvent_AudioStopped():
_statsTimer?.cancel();
_statsTimer = null;
case rust.BridgeEvent_SnapshotChanged():
setState(() {
if (_phase == ConnectionPhase.synchronizing) {
_phase = ConnectionPhase.connected;
}
});
unawaited(_onRefresh());
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(() {
_inChannel = inChannel;
_transmitMode = transmitMode;
_hardMute = mute;
if (!mute) _hardMuteByPermission = false;
_releaseTailMs = releaseTailMs;
_currentVoiceChannelId = currentChannelId;
_pendingVoiceChannelId = pendingTargetChannelId;
_canJoinVoiceChannel = canJoin;
});
if (inChannel) {
_ensureStatsTimer();
unawaited(_onRefresh());
} 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) {
messenger.showSnackBar(
SnackBar(
content: Text(AppL10n.of(context).iosAudioInterrupted),
duration: Duration(seconds: 3),
backgroundColor: Colors.orange,
),
);
} else if (shouldResume) {
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(() {
_chatMessages.add(
ChatEntry(
senderId: senderId,
senderName: senderName,
message: message,
target: target,
isSelf: senderId == _snapshot?.ownClientId,
timestamp: receivedAt,
),
);
if (_chatMessages.length > 200) {
_chatMessages.removeRange(0, _chatMessages.length - 200);
}
if (!_chatOpen && !isPoke) {
_chatUnread++;
}
});
if (isPoke) {
if (_showPokeDialogs) {
_appendPokeDialog(
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_AudioRouteChanged(:final route):
setState(() => _audioRoute = _routeIcon(route));
}
}
void _stopStatsPolling() {
_statsTimer?.cancel();
_statsTimer = null;
}
void _startStatsPollingIfConnected() {
if (_serverReachable) _ensureStatsTimer();
}
void _ensureStatsTimer() {
if (_statsTimer != null) return;
_statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async {
try {
final s = await rust.audioStats();
if (!mounted) return;
setState(() => _audioStats = s);
_statsTick += 1;
if (_statsTick % 5 == 0) {
unawaited(_refreshSnapshotForVoiceStatus());
}
} catch (_) {}
});
}
Future<void> _refreshSnapshotForVoiceStatus() async {
if (_voiceStatusRefreshInFlight) return;
_voiceStatusRefreshInFlight = true;
try {
final snap = await rust.snapshot();
if (!mounted) return;
setState(() => _applySnapshot(snap));
} catch (_) {
// Best-effort visual refresh only. Connection/loss paths still
// surface via the normal bridge events and explicit refreshes.
} finally {
_voiceStatusRefreshInFlight = 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();
_statsTimer?.cancel();
_voiceStatusRefreshInFlight = false;
_hostCtl.dispose();
_nickCtl.dispose();
_passwordCtl.dispose();
_pokeDialogPokes.dispose();
_androidPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged,
);
_iosPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged,
);
// 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();
super.dispose();
}
Future<void> _onConnect({
String? host,
String? nickname,
String? password,
}) async {
setState(() {
_phase = ConnectionPhase.connecting;
_error = null;
_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;
setState(() {
_phase = ConnectionPhase.synchronizing;
_applySnapshot(snap);
});
unawaited(
_saveUiSettings(
host: host ?? _hostCtl.text.trim(),
nickname: nickname ?? _nickCtl.text.trim(),
),
);
try {
await FlutterForegroundTask.startService(
notificationTitle: 'Chanora',
notificationText: 'Connected to ${snap.serverName}',
notificationButtons: const [
NotificationButton(id: 'disconnect', text: 'Disconnect'),
],
);
} catch (_) {}
} catch (e) {
if (!mounted) return;
final errorStr = e.toString();
if (errorStr.contains('PermissionDenied') ||
errorStr.contains('Operation not permitted')) {
_showPermissionDeniedDialog(errorStr);
}
setState(() {
_phase = ConnectionPhase.idle;
_error = errorStr;
});
}
}
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;
setState(() => _error = e.toString());
}
}
Future<void> _toggleOutputMute() async {
final next = !_outputMuted;
try {
await rust.setOutputMuted(muted: next);
if (!mounted) return;
setState(() {
_outputMuted = next;
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
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);
final messenger = ScaffoldMessenger.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 configureBundledVadModels();
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
if (!mounted) return;
unawaited(_onRefresh());
} catch (e) {
if (!mounted) return;
// Surface as a SnackBar so the user sees it even while
// connected (the persistent _error string lives in the
// pre-connect area and is hidden post-connect). The message
// is selected by TS3 error code per the canonical
// catalogue at https://github.com/ReSpeak/tsdeclarations.
final message = channelJoinErrorMessage(l10n, e);
setState(() => _pendingVoiceChannelId = null);
messenger.showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 4),
),
);
}
}
Future<void> _onToggleHardMute() async {
// Don't allow manual unmute when talk-power-muted.
if (_hardMuteByTalkPower && _hardMute) {
return;
}
final next = !_hardMute;
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);
if (!mounted) return;
setState(() {
_inputMuted = next;
_hardMute = next;
_hardMuteByPermission = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
/// 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;
setState(() => _error = e.toString());
}
}
}
Future<rust.BridgeAudioProcessingConfig> _loadAudioProcessingConfig() async {
try {
return await rust.getAudioProcessingConfig();
} catch (_) {
return defaultAudioProcessingConfig;
}
}
/// 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;
setState(() => _error = e.toString());
}
},
onReleaseTailChanged: (ms) async {
try {
await rust.setReleaseTailMs(ms: ms);
if (!mounted) return;
setState(() => _releaseTailMs = ms);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
},
onAudioConfigChanged: (config) async {
try {
await rust.setAudioProcessingConfig(config: config);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
},
);
}
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;
setState(() => _error = e.toString());
}
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> _onRefresh() async {
try {
final snap = await rust.snapshot();
if (!mounted) return;
setState(() => _applySnapshot(snap));
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _onDisconnect() async {
_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;
_statsTick = 0;
_voiceStatusRefreshInFlight = 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;
_error = null;
_inputMuted = false;
_outputMuted = false;
_inChannel = false;
_currentVoiceChannelId = null;
_pendingVoiceChannelId = null;
_canJoinVoiceChannel = true;
_lostReason = null;
_reconnectAttempt = null;
_reconnectDelay = null;
_chatMessages.clear();
_chatUnread = 0;
_chatOpen = false;
}
IconData _routeIcon(rust.BridgeAudioRoute r) {
switch (r) {
case rust.BridgeAudioRoute.earpiece:
return Icons.phone_android;
case rust.BridgeAudioRoute.speaker:
return Icons.volume_up;
case rust.BridgeAudioRoute.wiredHeadset:
return Icons.headset;
case rust.BridgeAudioRoute.bluetoothHfp:
case rust.BridgeAudioRoute.bluetoothA2Dp:
return Icons.bluetooth;
case rust.BridgeAudioRoute.unknown:
return Icons.help_outline;
}
}
Future<void> _onOpenChat({
rust.BridgeMessageTarget? target,
String clientName = '',
}) async {
setState(() {
_chatUnread = 0;
_chatOpen = true;
});
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatPage(
messages: _chatMessages,
snapshot: _snapshot!,
initialTarget:
target ??
resolveInitialChatTarget(
messages: _chatMessages,
currentVoiceChannelId: _currentVoiceChannelId,
),
initialClientName: clientName,
onTs3ServerLink: _onTs3ServerLink,
),
),
);
if (mounted) setState(() => _chatOpen = false);
}
void _appendPokeDialog({
required String senderName,
required String message,
required DateTime receivedAt,
}) {
_pokeDialogPokes.value = [
..._pokeDialogPokes.value,
_ReceivedPoke(
senderName: senderName,
message: message,
receivedAt: receivedAt,
),
];
unawaited(_showPokeDialog());
}
Future<void> _showPokeDialog() async {
if (_pokeDialogShowing || _pokeDialogPokes.value.isEmpty || !mounted) {
return;
}
_pokeDialogShowing = true;
try {
await showDialog<void>(
context: context,
builder: (ctx) => _PokeDialog(pokes: _pokeDialogPokes),
);
} finally {
_pokeDialogPokes.value = const [];
_pokeDialogShowing = false;
}
}
void _showChatMessageSnackBar({
required String senderName,
required String message,
required rust.BridgeMessageTarget target,
}) {
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: 'Open',
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 _applySnapshot(rust.BridgeSnapshot snap) {
_snapshot = snap;
final own = ownClientSnapshotState(snap);
if (own == null) return;
_currentVoiceChannelId = own.channelId;
_inputMuted = own.inputMuted;
_outputMuted = own.outputMuted;
_pendingVoiceChannelId = null;
_inChannel = true;
_canJoinVoiceChannel = true;
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
_hardMuteByTalkPower = true;
_hardMute = true;
rust.setHardMute(muted: true);
rust.setInputMuted(muted: true);
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
_hardMuteByTalkPower = false;
if (!_hardMuteByPermission) {
_hardMute = false;
rust.setHardMute(muted: false);
rust.setInputMuted(muted: false);
}
}
}
OwnClientSnapshotState? get _ownClientState {
final snap = _snapshot;
return snap == null ? null : ownClientSnapshotState(snap);
}
Future<void> _onShowDiagnostics(BuildContext context) async {
final l10n = AppL10n.of(context);
final text = rust.exportDiagnostics();
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.diagnosticsAction),
content: SingleChildScrollView(
child: SelectableText(
text,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
),
actions: [
TextButton(
onPressed: () async {
await SharePlus.instance.share(ShareParams(text: text));
},
child: const Text('Share'),
),
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: text));
if (!ctx.mounted) return;
Navigator.of(ctx).pop();
},
child: Text(l10n.copyAction),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.closeAction),
),
],
),
);
}
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;
final messenger = ScaffoldMessenger.of(this.context);
messenger.showSnackBar(
SnackBar(content: Text(l10n.statusError(e.toString()))),
);
}
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.
if (!mounted) return;
// Use the State's context (guaranteed valid because we
// re-checked `mounted` immediately above).
final messenger = ScaffoldMessenger.of(this.context);
messenger.showSnackBar(
SnackBar(content: Text(l10n.statusError(e.toString()))),
);
}
}
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 _onShowDiagnostics(context);
},
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 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;
setState(() => _error = e.toString());
}
}
Future<void> _onDeleteBookmark(rust.BridgeBookmark b) async {
try {
await rust.deleteBookmark(id: b.id);
await _reloadBookmarks();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
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 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;
setState(() => _error = e.toString());
}
}
Future<void> _onOpenAppSettings() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _AppSettingsPage(
showPokeDialogs: _showPokeDialogs,
onShowPokeDialogsChanged: (value) {
setState(() => _showPokeDialogs = value);
unawaited(_saveUiSettings(showPokeDialogs: value));
},
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final headerActions = [
if (_serverReachable && _inChannel) ...[
IconButton(
tooltip: _hardMuteByTalkPower
? 'Insufficient talk power to speak in this channel'
: l10n.voiceHardMuteLabel,
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
isSelected: _hardMute,
selectedIcon: const Icon(Icons.mic_off),
color: _hardMute ? theme.colorScheme.error : null,
onPressed: _hardMuteByTalkPower ? null : _onToggleHardMute,
),
IconButton(
tooltip: l10n.voiceOutputMuteLabel,
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
isSelected: _outputMuted,
selectedIcon: const Icon(Icons.headset_off),
color: _outputMuted ? theme.colorScheme.error : null,
onPressed: _toggleOutputMute,
),
],
IconButton(
tooltip: l10n.settingsAction,
icon: const Icon(Icons.settings_outlined),
onPressed: _onOpenAppSettings,
),
IconButton(
tooltip: l10n.aboutAction,
icon: const Icon(Icons.info_outline),
onPressed: () => _onShowAbout(context),
),
if (_phase.canOpenChat) ...[
Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
child: Badge(
isLabelVisible: _chatUnread > 0,
label: Text(_chatUnread.toString()),
child: IconButton(
tooltip: 'Chat',
icon: const Icon(Icons.chat_bubble_outline),
onPressed: _onOpenChat,
),
),
),
],
];
const headerTitle = SizedBox.shrink();
final appBarTitle = _serverReachable
? Text(
_snapshot?.serverName ?? l10n.appTitle,
style: theme.textTheme.titleMedium,
)
: headerTitle;
final connTokens = _phase.tokens(theme.colorScheme);
final statusText = connectionStatusText(
phase: _phase,
l10n: l10n,
error: _error,
serverName: _snapshot?.serverName,
lostReason: _lostReason,
reconnectAttempt: _reconnectAttempt,
reconnectDelay: _reconnectDelay,
);
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(
mainAxisSize: MainAxisSize.min,
children: [
Icon(connTokens.icon, size: 18, color: connTokens.color),
const SizedBox(width: 6),
Text(statusText, style: theme.textTheme.titleMedium),
],
),
if (_serverReachable && _audioRoute != null) ...[
const SizedBox(width: 8),
Icon(_audioRoute, size: 16, color: theme.colorScheme.tertiary),
],
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) ...[
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
),
] 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,
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),
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,
talkPower: ownClientState?.talkPower,
neededTalkPower: ownClientState?.neededTalkPower,
talkPowerGranted: ownClientState?.talkPowerGranted,
onTap: () => _onOpenVoiceDetailsSheet(),
),
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 Scaffold(
appBar: AppBar(
leading: _phase.canDisconnect
? IconButton(
tooltip: l10n.disconnectAction,
icon: const Icon(Icons.arrow_back),
onPressed: _onConfirmDisconnect,
)
: null,
title: appBarTitle,
actions: headerActions,
),
body: SafeArea(
top: false,
child: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
),
);
}
}
class _PokeDialog extends StatelessWidget {
const _PokeDialog({required this.pokes});
final ValueListenable<List<_ReceivedPoke>> pokes;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
return AlertDialog(
icon: Icon(
Icons.notifications_active_outlined,
color: theme.colorScheme.primary,
),
title: Text(l10n.pokeDialogTitle),
content: ValueListenableBuilder<List<_ReceivedPoke>>(
valueListenable: pokes,
builder: (context, entries, _) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360, maxHeight: 360),
child: ListView.separated(
shrinkWrap: true,
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (_, index) => _PokeDialogEntry(poke: entries[index]),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.pokeDialogCloseAction),
),
],
);
}
}
class _AppSettingsPage extends StatefulWidget {
const _AppSettingsPage({
required this.showPokeDialogs,
required this.onShowPokeDialogsChanged,
});
final bool showPokeDialogs;
final ValueChanged<bool> onShowPokeDialogsChanged;
@override
State<_AppSettingsPage> createState() => _AppSettingsPageState();
}
class _AppSettingsPageState extends State<_AppSettingsPage> {
late bool _showPokeDialogs = widget.showPokeDialogs;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.appSettingsTitle)),
body: ListView(
children: [
SwitchListTile(
title: Text(l10n.pokeAlertsTitle),
subtitle: Text(l10n.pokeAlertsDescription),
value: _showPokeDialogs,
onChanged: (value) {
setState(() => _showPokeDialogs = value);
widget.onShowPokeDialogsChanged(value);
},
),
],
),
);
}
}
class _PokeDialogEntry extends StatelessWidget {
const _PokeDialogEntry({required this.poke});
final _ReceivedPoke poke;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
final message = poke.message.trim();
final hasMessage = message.isNotEmpty;
final time = chatTimeLabel(poke.receivedAt);
final detail = hasMessage
? l10n.pokeDialogIncomingWithMessage(time, poke.senderName)
: l10n.pokeDialogIncomingNoMessage(time, poke.senderName);
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
detail,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (hasMessage) ...[const SizedBox(height: 8), Text(message)],
],
),
),
);
}
}