macOS realtime audio was suffering buffer underruns on CoreAudio's VPIO output callback. Root cause was twofold: AudioHandler was decoded under a Mutex held across the realtime callback, and the render path hard-coded mono i16 output regardless of the channel count the callback actually exposed (CoreAudio occasionally hands the callback stereo or quad output buffers, in which case writing only every Nth sample produced silence + clicks). This change brings macOS in line with the lock-free Android audio architecture introduced for output stutter elimination: * chanora_audio: AudioPacket / AudioCommand / AudioEventQueue (previously gated to `target_os = "android"`) are now compiled on macOS too. The decode loop in AudioEngine pushes inbound packets into the queue; the VPIO render callback owns AudioHandler outright and drains the queue, so the realtime thread never blocks on a cross-thread mutex. set_client_volume also routes through the command queue on macOS instead of locking the handler. * voice_render.rs: new downmix_stereo_f32_to_interleaved_i16 helper downmixes stereo f32 from AudioHandler to mono i16 and replicates that mono sample across every output channel the callback exposes. The existing downmix_stereo_f32_to_mono_i16 helper is retained for iOS, where VPIO is reliably configured for single-channel output via the AudioUnit stream format we pin at unit-create time. Compile-gated to ios + test so the macos build doesn't warn on dead code. * ios_voice_unit.rs: render callback reads data.channels from the args struct and forwards it to the new interleaved helper, so the macOS path tolerates whatever channel count CoreAudio assigns. A level decimation counter avoids running sqrt+log10 on every callback (~93 Hz) when the Flutter consumer only reads at 30 Hz; same regression class as the capture-side fix already in engine.rs. * mobile_voice_backend.rs: VoiceAudioParams now carries event_producer on macOS, and the AudioHandler is no longer wrapped in Arc<Mutex<…>> on macOS because ownership moves into the render callback. iOS keeps Arc<Mutex<…>> because its callback design shares the handler with the decode task. * lib.rs: audio_event_queue module is now compiled on macOS in addition to android. apps/chanora_flutter/lib/main.dart wraps the home tree in a Stack and overlays AudioDebugStatsPanel on macOS so the live engine counters (callback rate, drift, queue depth) used to diagnose the underrun are visible while iterating on this code. iOS and other platforms are unaffected. apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework binary is rebuilt with these changes so flutter run on macOS picks up the new realtime path without requiring developers to rebuild the Rust crate locally. cargo check -p chanora_audio passes on macOS host.
2921 lines
97 KiB
Dart
2921 lines
97 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 'design/breakpoints.dart';
|
|
import 'design/viewport_info.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/audio_processing_config_state.dart';
|
|
import 'widgets/audio_debug_stats_panel.dart';
|
|
import 'widgets/chat_panel.dart';
|
|
import 'widgets/chat_views.dart';
|
|
import 'widgets/client_info_sheet.dart';
|
|
import 'widgets/connect_widgets.dart';
|
|
import 'widgets/input_dialogs.dart';
|
|
import 'widgets/permission_state_banner.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;
|
|
bool get _showAudioDebugOverlay => _isMacOS;
|
|
|
|
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: Stack(
|
|
children: [
|
|
_BetaHome(
|
|
themeMode: _themeMode,
|
|
onThemeModeChanged: _setThemeMode,
|
|
),
|
|
if (_showAudioDebugOverlay) const AudioDebugStatsPanel(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 {
|
|
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;
|
|
rust.BridgeMessageTarget? _inlineChatTarget;
|
|
String _inlineChatClientName = '';
|
|
bool _inlineChatCollapseNoticeShown = false;
|
|
|
|
/// Per-target draft text. Populated when switching away from a conversation
|
|
/// so the user's unfinished message is preserved.
|
|
final Map<String, String> _chatDrafts = {};
|
|
|
|
/// Channel IDs that have unread chat messages (used for dot indicators).
|
|
Set<BigInt> get _unreadChannelIds {
|
|
if (_chatOpen) return const {};
|
|
final ids = <BigInt>{};
|
|
for (final entry in _chatMessages) {
|
|
if (!entry.countsTowardUnread || entry.isSelf) continue;
|
|
final target = entry.target;
|
|
if (target is rust.BridgeMessageTarget_Channel) {
|
|
// Channel target has no ID payload — it means "current channel".
|
|
// We can't distinguish per-channel without the channel ID in the target.
|
|
// For now, if there are any unread channel messages, mark the current voice channel.
|
|
if (_currentVoiceChannelId != null) {
|
|
ids.add(_currentVoiceChannelId!);
|
|
}
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
/// The last chat target before the panel was closed. Used to restore the
|
|
/// previous conversation when the user reopens chat.
|
|
rust.BridgeMessageTarget? _lastDismissedTarget;
|
|
String _lastDismissedClientName = '';
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showLocalNetworkDeniedSnackBar() {
|
|
if (!mounted) return;
|
|
final l10n = AppL10n.of(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
duration: const Duration(seconds: 8),
|
|
content: Text(
|
|
l10n.networkPermissionBody,
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
action: SnackBarAction(
|
|
label: l10n.networkPermissionOpenSettings,
|
|
onPressed: () {
|
|
try {
|
|
Process.run('open', [
|
|
'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
|
|
]);
|
|
} catch (_) {}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
// Trigger macOS Local Network permission prompt before connecting.
|
|
// On macOS 15+ the first network connection triggers the system
|
|
// dialog; triggering it via NWBrowser here ensures the prompt
|
|
// appears *before* the actual connect attempt so the user can grant
|
|
// permission and the connection succeeds in one flow.
|
|
final lnState = _macOSPermissions.localNetworkState.value;
|
|
if (lnState == MacOSLocalNetworkState.unknown ||
|
|
lnState == MacOSLocalNetworkState.notDetermined) {
|
|
await _macOSPermissions.triggerLocalNetworkPrompt();
|
|
}
|
|
|
|
// If the prompt resolved to Denied (or was already Denied), confirm
|
|
// with a read-only NWConnection probe to the target host and show
|
|
// a snackbar directing the user to System Settings.
|
|
final resolvedState = _macOSPermissions.localNetworkState.value;
|
|
if (resolvedState == MacOSLocalNetworkState.denied) {
|
|
final targetHost = (host ?? _hostCtl.text).trim();
|
|
final accessState = await _macOSPermissions.checkLocalNetworkAccess(
|
|
host: targetHost,
|
|
port: 9987,
|
|
);
|
|
if (accessState == MacOSLocalNetworkState.denied) {
|
|
if (!mounted) return;
|
|
setState(() { _phase = ConnectionPhase.idle; });
|
|
_showLocalNetworkDeniedSnackBar();
|
|
return;
|
|
}
|
|
}
|
|
|
|
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;
|
|
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 (_) {}
|
|
}
|
|
|
|
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();
|
|
_chatDrafts.clear();
|
|
_chatUnread = 0;
|
|
_chatOpen = false;
|
|
_inlineChatTarget = null;
|
|
_inlineChatClientName = '';
|
|
_inlineChatCollapseNoticeShown = false;
|
|
_lastDismissedTarget = null;
|
|
_lastDismissedClientName = '';
|
|
_notifyChatFeedChanged();
|
|
}
|
|
|
|
/// Converts a [rust.BridgeMessageTarget] to a stable string key for draft storage.
|
|
String _draftKeyForTarget(rust.BridgeMessageTarget target) {
|
|
return switch (target) {
|
|
rust.BridgeMessageTarget_Server() => 'server',
|
|
rust.BridgeMessageTarget_Channel() => 'channel',
|
|
rust.BridgeMessageTarget_Client(:final field0) => 'client:$field0',
|
|
rust.BridgeMessageTarget_Poke(:final field0) => 'poke:$field0',
|
|
};
|
|
}
|
|
|
|
/// Saves the current draft text for the current inline chat target.
|
|
void _saveCurrentDraft() {
|
|
// Drafts are continuously saved via onDraftChanged callback in Task 4.
|
|
// This method exists as an explicit save point.
|
|
final target = _inlineChatTarget;
|
|
if (target == null) return;
|
|
final key = _draftKeyForTarget(target);
|
|
if (_chatDrafts.containsKey(key)) {
|
|
_chatDrafts[key] = _chatDrafts[key] ?? '';
|
|
}
|
|
}
|
|
|
|
Future<void> _onOpenChat({
|
|
rust.BridgeMessageTarget? target,
|
|
String clientName = '',
|
|
}) async {
|
|
final initialSnapshot = _snapshot!;
|
|
|
|
// Resolve the new target.
|
|
// If no target passed and panel is already open, switch to current voice channel.
|
|
// If no target passed and panel is closed, resolve from history or default.
|
|
rust.BridgeMessageTarget newTarget;
|
|
if (target != null) {
|
|
newTarget = target;
|
|
} else if (_chatOpen && _currentVoiceChannelId != null) {
|
|
newTarget = const rust.BridgeMessageTarget.channel();
|
|
} else if (_inlineChatTarget != null) {
|
|
newTarget = _inlineChatTarget!;
|
|
} else {
|
|
newTarget =
|
|
resolveInitialChatTarget(
|
|
messages: _chatMessages,
|
|
currentVoiceChannelId: _currentVoiceChannelId,
|
|
) ??
|
|
const rust.BridgeMessageTarget.server();
|
|
}
|
|
|
|
final newClientName = clientName.isNotEmpty
|
|
? clientName
|
|
: (newTarget == _inlineChatTarget)
|
|
? _inlineChatClientName
|
|
: (newTarget == _lastDismissedTarget)
|
|
? _lastDismissedClientName
|
|
: '';
|
|
|
|
final isExpanded =
|
|
layoutClassFromWidth(MediaQuery.sizeOf(context).width) ==
|
|
LayoutClass.expanded;
|
|
if (isExpanded) {
|
|
setState(() {
|
|
_saveCurrentDraft();
|
|
_chatUnread = 0;
|
|
_chatOpen = true;
|
|
_inlineChatTarget = newTarget;
|
|
_inlineChatClientName = newClientName;
|
|
_inlineChatCollapseNoticeShown = false;
|
|
});
|
|
return;
|
|
}
|
|
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: newTarget,
|
|
initialClientName: newClientName,
|
|
onTs3ServerLink: _onTs3ServerLink,
|
|
),
|
|
),
|
|
);
|
|
if (mounted) setState(() => _chatOpen = false);
|
|
}
|
|
|
|
void _closeInlineChat() {
|
|
setState(() {
|
|
_saveCurrentDraft();
|
|
_lastDismissedTarget = _inlineChatTarget;
|
|
_lastDismissedClientName = _inlineChatClientName;
|
|
_chatOpen = false;
|
|
// Do NOT null _inlineChatTarget — remember it for reopen.
|
|
});
|
|
}
|
|
|
|
void _handleInlineChatViewport(LayoutClass layoutClass) {
|
|
if (_inlineChatTarget == null) return;
|
|
if (layoutClass == LayoutClass.expanded) {
|
|
_inlineChatCollapseNoticeShown = false;
|
|
if (!_chatOpen) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && _inlineChatTarget != null) {
|
|
setState(() => _chatOpen = true);
|
|
}
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (_chatOpen) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && _inlineChatTarget != null) {
|
|
setState(() => _chatOpen = false);
|
|
}
|
|
});
|
|
}
|
|
if (_inlineChatCollapseNoticeShown) return;
|
|
_inlineChatCollapseNoticeShown = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || _inlineChatTarget == null) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Tap the chat button to continue your conversation'),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
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 >= ChanoraBreakpoints.medium &&
|
|
_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 < ChanoraBreakpoints.medium;
|
|
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 layoutClass = layoutClassFromWidth(bodyConstraints.maxWidth);
|
|
_handleInlineChatViewport(layoutClass);
|
|
final isWideSnapshot =
|
|
bodyConstraints.maxWidth >= ChanoraBreakpoints.medium &&
|
|
_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 ViewportInfo(
|
|
layoutClass: layoutClass,
|
|
width: bodyConstraints.maxWidth,
|
|
height: bodyConstraints.maxHeight,
|
|
child: 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,
|
|
unreadChannelIds: _unreadChannelIds,
|
|
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,
|
|
),
|
|
),
|
|
onOpenChannelChat: (channel) => unawaited(
|
|
_onOpenChat(
|
|
target: const rust.BridgeMessageTarget.channel(),
|
|
),
|
|
),
|
|
onTs3ServerLink: _onTs3ServerLink,
|
|
);
|
|
final ownClientState = _ownClientState;
|
|
final layoutInfo = ViewportInfo.of(ctx);
|
|
if (layoutInfo.isWide) {
|
|
final inlineChatTarget = _inlineChatTarget;
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SizedBox(
|
|
width: ChanoraBreakpoints.voicePanelWidth,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
banner,
|
|
const SizedBox(height: 12),
|
|
permissionBanner,
|
|
voiceBar,
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: ChanoraBreakpoints.panelGap),
|
|
Expanded(child: snapshotView),
|
|
if (layoutInfo.isExpanded &&
|
|
inlineChatTarget != null) ...[
|
|
const SizedBox(
|
|
width: ChanoraBreakpoints.panelGap,
|
|
),
|
|
ChatPanel(
|
|
messages: _chatMessages,
|
|
snapshot: _snapshot!,
|
|
target: inlineChatTarget,
|
|
clientName: _inlineChatClientName,
|
|
restoredDraft:
|
|
_chatDrafts[_draftKeyForTarget(
|
|
inlineChatTarget,
|
|
)],
|
|
onDraftChanged: (text) {
|
|
final draftKey = _draftKeyForTarget(
|
|
inlineChatTarget,
|
|
);
|
|
_chatDrafts[draftKey] = text;
|
|
},
|
|
onTs3ServerLink: _onTs3ServerLink,
|
|
onClose: _closeInlineChat,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
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)}';
|
|
}
|