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