feat: integrate chat voice and diagnostics client
This commit is contained in:
+170
-199
@@ -145,7 +145,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
ConnectionPhase _phase = ConnectionPhase.idle;
|
||||
bool get _serverReachable => _phase.isServerReachable;
|
||||
rust.BridgeSnapshot? _snapshot;
|
||||
String? _error;
|
||||
final List<String> _uiDiagnostics = [];
|
||||
rust.BridgeAudioStats? _audioStats;
|
||||
Timer? _statsTimer;
|
||||
int _statsTick = 0;
|
||||
@@ -195,11 +195,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
final List<ChatEntry> _chatMessages = [];
|
||||
int _chatUnread = 0;
|
||||
bool _chatOpen = false;
|
||||
bool _showPokeDialogs = true;
|
||||
final ValueNotifier<List<_ReceivedPoke>> _pokeDialogPokes = ValueNotifier(
|
||||
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
|
||||
const [],
|
||||
);
|
||||
bool _pokeDialogShowing = false;
|
||||
bool _pokeSnackBarVisible = false;
|
||||
IconData? _audioRoute;
|
||||
|
||||
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
|
||||
@@ -246,25 +245,12 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
if (settings.nickname.isNotEmpty) {
|
||||
_nickCtl.text = settings.nickname;
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _showPokeDialogs = settings.showPokeDialogs);
|
||||
} else {
|
||||
_showPokeDialogs = settings.showPokeDialogs;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _saveUiSettings({
|
||||
String? host,
|
||||
String? nickname,
|
||||
bool? showPokeDialogs,
|
||||
}) async {
|
||||
Future<void> _saveUiSettings({String? host, String? nickname}) async {
|
||||
try {
|
||||
await _uiPreferences.saveSettings(
|
||||
host: host,
|
||||
nickname: nickname,
|
||||
showPokeDialogs: showPokeDialogs,
|
||||
);
|
||||
await _uiPreferences.saveSettings(host: host, nickname: nickname);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -342,12 +328,45 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
_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 =
|
||||
@@ -564,13 +583,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
}
|
||||
});
|
||||
if (isPoke) {
|
||||
if (_showPokeDialogs) {
|
||||
_appendPokeDialog(
|
||||
senderName: senderName,
|
||||
message: message,
|
||||
receivedAt: receivedAt,
|
||||
);
|
||||
}
|
||||
_showPokeSnackBar(
|
||||
senderName: senderName,
|
||||
message: message,
|
||||
receivedAt: receivedAt,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!_chatOpen && _chatUnread > 0) {
|
||||
@@ -656,7 +673,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_hostCtl.dispose();
|
||||
_nickCtl.dispose();
|
||||
_passwordCtl.dispose();
|
||||
_pokeDialogPokes.dispose();
|
||||
_pokeSnackBarPokes.dispose();
|
||||
_androidPermissions.recordAudioState.removeListener(
|
||||
_onRecordAudioPermissionChanged,
|
||||
);
|
||||
@@ -678,7 +695,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
}) async {
|
||||
setState(() {
|
||||
_phase = ConnectionPhase.connecting;
|
||||
_error = null;
|
||||
_snapshot = null;
|
||||
_chatMessages.clear();
|
||||
});
|
||||
@@ -718,8 +734,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
}
|
||||
setState(() {
|
||||
_phase = ConnectionPhase.idle;
|
||||
_error = errorStr;
|
||||
});
|
||||
_showUiErrorSnackBar(area: 'connect', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,7 +802,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await rust.setPtt(active: active);
|
||||
} catch (e) {
|
||||
if (!mounted || !reportError) return;
|
||||
setState(() => _error = e.toString());
|
||||
_showUiError('ptt', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,7 +816,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
_showUiError('output mute', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,7 +828,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
if (_pendingVoiceChannelId != null) return;
|
||||
if (ch.id == _currentVoiceChannelId) return;
|
||||
final l10n = AppL10n.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
String? password;
|
||||
if (askForPassword) {
|
||||
password = await _askChannelPassword(l10n);
|
||||
@@ -872,18 +887,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
// Surface as a SnackBar so the user sees it even while
|
||||
// connected (the persistent _error string lives in the
|
||||
// pre-connect area and is hidden post-connect). The message
|
||||
// is selected by TS3 error code per the canonical
|
||||
// catalogue at https://github.com/ReSpeak/tsdeclarations.
|
||||
// connected. The message is selected by TS3 error code per
|
||||
// the canonical catalogue at
|
||||
// https://github.com/ReSpeak/tsdeclarations.
|
||||
final message = channelJoinErrorMessage(l10n, e);
|
||||
setState(() => _pendingVoiceChannelId = null);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
_showUiErrorSnackBar(
|
||||
area: 'join channel',
|
||||
error: e,
|
||||
displayMessage: message,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -914,7 +926,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
_showUiError('hard mute', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,7 +950,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
} catch (e) {
|
||||
if (held) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
_showUiError('onscreen ptt', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -977,7 +989,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
setState(() => _transmitMode = mode);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
_showUiError('transmit mode', e);
|
||||
}
|
||||
},
|
||||
onReleaseTailChanged: (ms) async {
|
||||
@@ -987,7 +999,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
setState(() => _releaseTailMs = ms);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
_showUiError('release tail', e);
|
||||
}
|
||||
},
|
||||
onAudioConfigChanged: (config) async {
|
||||
@@ -995,7 +1007,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await rust.setAudioProcessingConfig(config: config);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
_showUiError('audio processing config', e);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1023,7 +1035,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await rust.setAudioProcessingConfig(config: result.audioConfig);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
_showUiError('voice settings', e);
|
||||
}
|
||||
if (result.bindKeyRequested && mounted) {
|
||||
await _onConfigurePtt(context);
|
||||
@@ -1055,7 +1067,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
setState(() => _applySnapshot(snap));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
_showUiError('refresh snapshot', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1113,7 +1125,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_phase = phase;
|
||||
_snapshot = null;
|
||||
_audioStats = null;
|
||||
_error = null;
|
||||
_inputMuted = false;
|
||||
_outputMuted = false;
|
||||
_inChannel = false;
|
||||
@@ -1171,36 +1182,52 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
if (mounted) setState(() => _chatOpen = false);
|
||||
}
|
||||
|
||||
void _appendPokeDialog({
|
||||
void _showPokeSnackBar({
|
||||
required String senderName,
|
||||
required String message,
|
||||
required DateTime receivedAt,
|
||||
}) {
|
||||
_pokeDialogPokes.value = [
|
||||
..._pokeDialogPokes.value,
|
||||
_pokeSnackBarPokes.value = [
|
||||
..._pokeSnackBarPokes.value,
|
||||
_ReceivedPoke(
|
||||
senderName: senderName,
|
||||
message: message,
|
||||
receivedAt: receivedAt,
|
||||
),
|
||||
];
|
||||
unawaited(_showPokeDialog());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _renderPokeSnackBar();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showPokeDialog() async {
|
||||
if (_pokeDialogShowing || _pokeDialogPokes.value.isEmpty || !mounted) {
|
||||
return;
|
||||
}
|
||||
_pokeDialogShowing = true;
|
||||
try {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => _PokeDialog(pokes: _pokeDialogPokes),
|
||||
);
|
||||
} finally {
|
||||
_pokeDialogPokes.value = const [];
|
||||
_pokeDialogShowing = false;
|
||||
}
|
||||
void _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({
|
||||
@@ -1208,6 +1235,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
required String message,
|
||||
required rust.BridgeMessageTarget target,
|
||||
}) {
|
||||
if (_pokeSnackBarPokes.value.isNotEmpty) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.hideCurrentSnackBar();
|
||||
messenger.showSnackBar(
|
||||
@@ -1313,7 +1341,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
|
||||
Future<void> _onShowDiagnostics(BuildContext context) async {
|
||||
final l10n = AppL10n.of(context);
|
||||
final text = rust.exportDiagnostics();
|
||||
final rustText = rust.exportDiagnostics();
|
||||
final uiText = _uiDiagnostics.isEmpty
|
||||
? 'UI diagnostics: none'
|
||||
: ['UI diagnostics:', ..._uiDiagnostics].join('\n');
|
||||
final text = '$uiText\n\nRust diagnostics:\n$rustText';
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
@@ -1370,10 +1402,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(this.context);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.statusError(e.toString()))),
|
||||
);
|
||||
_showUiErrorSnackBar(area: 'ptt portal binding', error: e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1405,13 +1434,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
if (!mounted) return;
|
||||
// Surface the failure as a snackbar so the user sees that
|
||||
// their binding did not stick.
|
||||
if (!mounted) return;
|
||||
// Use the State's context (guaranteed valid because we
|
||||
// re-checked `mounted` immediately above).
|
||||
final messenger = ScaffoldMessenger.of(this.context);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.statusError(e.toString()))),
|
||||
);
|
||||
_showUiErrorSnackBar(area: 'ptt binding', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1514,7 +1537,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await _reloadBookmarks();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
_showUiError('add bookmark', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1524,7 +1547,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await _reloadBookmarks();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
_showUiError('delete bookmark', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1573,24 +1596,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
).showSnackBar(SnackBar(content: Text('Added bookmark "$bookmarkName"')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
_showUiError('teamspeak link bookmark', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onOpenAppSettings() async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _AppSettingsPage(
|
||||
showPokeDialogs: _showPokeDialogs,
|
||||
onShowPokeDialogsChanged: (value) {
|
||||
setState(() => _showPokeDialogs = value);
|
||||
unawaited(_saveUiSettings(showPokeDialogs: value));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
@@ -1617,11 +1626,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
onPressed: _toggleOutputMute,
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
tooltip: l10n.settingsAction,
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: _onOpenAppSettings,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.aboutAction,
|
||||
icon: const Icon(Icons.info_outline),
|
||||
@@ -1656,7 +1660,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
final statusText = connectionStatusText(
|
||||
phase: _phase,
|
||||
l10n: l10n,
|
||||
error: _error,
|
||||
serverName: _snapshot?.serverName,
|
||||
lostReason: _lostReason,
|
||||
reconnectAttempt: _reconnectAttempt,
|
||||
@@ -1686,11 +1689,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
|
||||
if (!_serverReachable)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(connTokens.icon, size: 18, color: connTokens.color),
|
||||
const SizedBox(width: 6),
|
||||
Text(statusText, style: theme.textTheme.titleMedium),
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusText,
|
||||
softWrap: true,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_serverReachable && _audioRoute != null) ...[
|
||||
@@ -1928,75 +1936,74 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
class _PokeDialog extends StatelessWidget {
|
||||
const _PokeDialog({required this.pokes});
|
||||
class _PokeSnackBarContent extends StatelessWidget {
|
||||
const _PokeSnackBarContent({required this.pokes});
|
||||
|
||||
final ValueListenable<List<_ReceivedPoke>> pokes;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppL10n.of(context);
|
||||
|
||||
return AlertDialog(
|
||||
icon: Icon(
|
||||
Icons.notifications_active_outlined,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(l10n.pokeDialogTitle),
|
||||
content: ValueListenableBuilder<List<_ReceivedPoke>>(
|
||||
valueListenable: pokes,
|
||||
builder: (context, entries, _) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360, maxHeight: 360),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, index) => _PokeDialogEntry(poke: entries[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.pokeDialogCloseAction),
|
||||
),
|
||||
],
|
||||
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 _AppSettingsPage extends StatefulWidget {
|
||||
const _AppSettingsPage({
|
||||
required this.showPokeDialogs,
|
||||
required this.onShowPokeDialogsChanged,
|
||||
});
|
||||
class _PokeSnackBarRow extends StatelessWidget {
|
||||
const _PokeSnackBarRow({required this.poke});
|
||||
|
||||
final bool showPokeDialogs;
|
||||
final ValueChanged<bool> onShowPokeDialogsChanged;
|
||||
|
||||
@override
|
||||
State<_AppSettingsPage> createState() => _AppSettingsPageState();
|
||||
}
|
||||
|
||||
class _AppSettingsPageState extends State<_AppSettingsPage> {
|
||||
late bool _showPokeDialogs = widget.showPokeDialogs;
|
||||
final _ReceivedPoke poke;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.appSettingsTitle)),
|
||||
body: ListView(
|
||||
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: [
|
||||
SwitchListTile(
|
||||
title: Text(l10n.pokeAlertsTitle),
|
||||
subtitle: Text(l10n.pokeAlertsDescription),
|
||||
value: _showPokeDialogs,
|
||||
onChanged: (value) {
|
||||
setState(() => _showPokeDialogs = value);
|
||||
widget.onShowPokeDialogsChanged(value);
|
||||
},
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -2004,43 +2011,7 @@ class _AppSettingsPageState extends State<_AppSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _PokeDialogEntry extends StatelessWidget {
|
||||
const _PokeDialogEntry({required this.poke});
|
||||
|
||||
final _ReceivedPoke poke;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppL10n.of(context);
|
||||
final message = poke.message.trim();
|
||||
final hasMessage = message.isNotEmpty;
|
||||
final time = chatTimeLabel(poke.receivedAt);
|
||||
final detail = hasMessage
|
||||
? l10n.pokeDialogIncomingWithMessage(time, poke.senderName)
|
||||
: l10n.pokeDialogIncomingNoMessage(time, poke.senderName);
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
detail,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (hasMessage) ...[const SizedBox(height: 8), Text(message)],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
String pokeSnackBarTimeLabel(DateTime timestamp) {
|
||||
String two(int value) => value.toString().padLeft(2, '0');
|
||||
return '${two(timestamp.hour)}:${two(timestamp.minute)}';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user