Fix null check crash in TabBar

This commit is contained in:
Janez T
2026-03-05 11:41:15 +01:00
parent 234edf5bb0
commit 0cb1d49804
14 changed files with 660 additions and 286 deletions

View File

@@ -59,6 +59,11 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return Consumer<ip.ImageProvider>(
builder: (context, imageProvider, _) {
final session = imageProvider.session(envelope.sessionId);
final sender = _resolveSender(envelope);
final effectivePathLen =
sender != null && sender.outPathLen >= 0
? sender.outPathLen
: widget.message.pathLen;
final isComplete = imageProvider.isComplete(envelope.sessionId);
final eta = imageProvider.estimateRemainingTransferTime(
envelope.sessionId,
@@ -98,6 +103,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: effectivePathLen,
),
),
const SizedBox(height: 4),
@@ -109,13 +115,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
received: received,
total: total,
envelope: envelope,
pathLen: widget.message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
error: _errorText,
isSentByMe: widget.isSentByMe,
eta: eta,
pathLen: effectivePathLen,
),
style: TextStyle(
fontSize: 11,
@@ -143,6 +149,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required int? radioBw,
required int? radioSf,
required int? radioCr,
required int pathLen,
}) {
if (isComplete && imageBytes != null) {
return AspectRatio(
@@ -184,7 +191,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: widget.message.pathLen,
pathLen: pathLen,
),
icon: const Icon(Icons.download_rounded, size: 40),
color: Colors.white70,
@@ -298,12 +305,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (!mounted) return;
// Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen;
final txEstimate = estimateImageTransmitDuration(
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
sizeBytes: missing.isEmpty
? envelope.sizeBytes
: (envelope.sizeBytes * missing.length / envelope.total).round(),
pathLen: pathLen,
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
@@ -335,6 +343,19 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
);
if (contact != null) return contact;
// For sent direct messages, fetch must target the recipient peer.
final recipientKey = widget.message.recipientPublicKey;
if (widget.isSentByMe && recipientKey != null && recipientKey.isNotEmpty) {
final byKey = contactsProvider.findContactByKey(recipientKey);
if (byKey != null) return byKey;
if (recipientKey.length >= 6) {
final byPrefix = contactsProvider.findContactByPrefix(
Uint8List.fromList(recipientKey.sublist(0, 6)),
);
if (byPrefix != null) return byPrefix;
}
}
final senderName = widget.message.senderName?.trim();
if (senderName != null && senderName.isNotEmpty) {
for (final c in contactsProvider.contacts) {

View File

@@ -22,6 +22,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final event = TicTacToeMessageParser.tryParse(message.text);
if (event == null) {
return const SizedBox.shrink();
@@ -82,6 +83,12 @@ class TicTacToeMessageBubble extends StatelessWidget {
final mySymbol = selfKey6 == state.xPlayerKey6 ? 'X' : 'O';
final isMyTurn = !state.isFinished && state.nextSymbol == mySymbol;
final titleColor = isSentByMe
? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
final statusColor = isSentByMe
? colorScheme.onPrimaryContainer.withValues(alpha: 0.85)
: colorScheme.onSurface.withValues(alpha: 0.85);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 230),
@@ -92,12 +99,16 @@ class TicTacToeMessageBubble extends StatelessWidget {
'Tic-Tac-Toe · Game ${state.gameId}',
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.bold),
).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
color: titleColor,
),
),
const SizedBox(height: 8),
_BoardGrid(
board: state.board,
enabled: isMyTurn,
isSentByMe: isSentByMe,
onTapCell: (idx) => _onCellTap(
context: context,
idx: idx,
@@ -110,7 +121,9 @@ class TicTacToeMessageBubble extends StatelessWidget {
const SizedBox(height: 8),
Text(
_statusText(state: state, mySymbol: mySymbol),
style: Theme.of(context).textTheme.labelSmall,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: statusColor),
),
],
),
@@ -221,16 +234,26 @@ class TicTacToeMessageBubble extends StatelessWidget {
class _BoardGrid extends StatelessWidget {
final List<String?> board;
final bool enabled;
final bool isSentByMe;
final ValueChanged<int> onTapCell;
const _BoardGrid({
required this.board,
required this.enabled,
required this.isSentByMe,
required this.onTapCell,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final cellBackground = isSentByMe
? colorScheme.primaryContainer.withValues(alpha: 0.35)
: colorScheme.surface;
final cellBorder = isSentByMe
? colorScheme.primary.withValues(alpha: 0.45)
: colorScheme.outline.withValues(alpha: 0.35);
return SizedBox(
width: 180,
height: 180,
@@ -251,12 +274,18 @@ class _BoardGrid extends StatelessWidget {
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
color: cellBackground,
border: Border.all(color: cellBorder),
),
child: Text(
value ?? '',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
color: value == 'X'
? colorScheme.primary
: value == 'O'
? colorScheme.tertiary
: null,
),
),
),

View File

@@ -57,6 +57,11 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
builder: (context, voiceProvider, _) {
final session = voiceProvider.session(voiceId);
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final sender = _resolveSenderContact();
final effectivePathLen =
sender != null && sender.outPathLen >= 0
? sender.outPathLen
: widget.message.pathLen;
final isPlaying = voiceProvider.isPlaying(voiceId);
final isComplete = voiceProvider.isComplete(voiceId);
@@ -96,7 +101,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
session: session,
envelope: envelope,
messageText: widget.message.text,
pathLen: widget.message.pathLen,
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
@@ -123,7 +128,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: widget.message.pathLen,
pathLen: effectivePathLen,
);
},
borderRadius: BorderRadius.circular(24),
@@ -280,12 +285,13 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
}
// Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen;
final txEstimate = envelope != null
? estimateVoiceTransmitDuration(
packetCount: envelope.total,
mode: envelope.mode,
durationMs: envelope.durationMs,
pathLen: pathLen,
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
@@ -329,6 +335,19 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (contact != null) return contact;
}
// For sent direct messages, fetch must target the recipient peer.
final recipientKey = widget.message.recipientPublicKey;
if (widget.isSentByMe && recipientKey != null && recipientKey.isNotEmpty) {
final byKey = contactsProvider.findContactByKey(recipientKey);
if (byKey != null) return byKey;
if (recipientKey.length >= 6) {
final byPrefix = contactsProvider.findContactByPrefix(
Uint8List.fromList(recipientKey.sublist(0, 6)),
);
if (byPrefix != null) return byPrefix;
}
}
final senderName = widget.message.senderName?.trim();
if (senderName != null && senderName.isNotEmpty) {
for (final contact in contactsProvider.contacts) {