part of '../message_group.dart'; // Sprechblasen-Darstellung (Einstellung „Nachrichten: Sprechblasen“, auf dem // Handy Standard – Bernds Entscheidung 2026-10-07). Eigene Nachrichten rechts // in Akzentfarbe, andere links; in Gruppenräumen mit Name + Avatar, in // Direktnachrichten ohne. Bilder/Videos ohne Blase, dafür in voller Größe. // Inhalt (Text, Antwort-Zitat, Medien), Reaktionen, Auswahl, Hover-Aktionen // und Wischen-zum-Antworten kommen aus denselben Bausteinen wie die Liste. const _kBubbleMaxWidthFactor = 0.82; const _kBubbleAvatarSize = 30.0; const _kBubbleAvatarGap = 8.0; extension _BubbleBuilder on _MessageGroupState { Widget _buildBubbles(BuildContext context, PyramidTheme pt) { final firstEvent = widget.events.first; final isOwn = firstEvent.senderId == widget.currentUserId; final isDm = widget.room?.isDirectChat ?? false; // Name + Avatar nur bei fremden Nachrichten in Gruppenräumen. final showSender = !isOwn && !isDm; final sender = firstEvent.senderFromMemoryOrFallback; final name = sender.calcDisplayname(); final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; final color = _colorFromId(firstEvent.senderId); return LayoutBuilder(builder: (context, constraints) { final rowWidth = constraints.maxWidth - 24; // 12 px Rand links/rechts final senderSpace = showSender ? _kBubbleAvatarSize + _kBubbleAvatarGap : 0.0; final maxBubbleWidth = ((rowWidth - senderSpace) * _kBubbleMaxWidthFactor).clamp(120.0, 560.0); final rows = []; for (var j = 0; j < widget.events.length; j++) { final event = widget.events[j]; final isFirst = j == 0; final isLast = j == widget.events.length - 1; final bubbleColumn = Column( crossAxisAlignment: isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _Bubble( event: event, pt: pt, isOwn: isOwn, isFirst: isFirst, isLast: isLast, senderName: showSender && isFirst ? name : null, senderColor: color, maxWidth: maxBubbleWidth, timeline: widget.timeline, ), if (widget.timeline != null) _ReactionsRow( event: event, timeline: widget.timeline!, room: widget.room, currentUserId: widget.currentUserId, pt: pt, ), ], ); final line = Row( mainAxisAlignment: isOwn ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showSender) ...[ SizedBox( width: _kBubbleAvatarSize, child: isFirst ? _Avatar( initial: initial, color: color, pt: pt, size: _kBubbleAvatarSize, avatarUri: sender.avatarUrl, ) : null, ), const SizedBox(width: _kBubbleAvatarGap), ], Flexible(child: bubbleColumn), ], ); rows.add(_selectableRow( event, pt, Padding( padding: EdgeInsets.only( top: isFirst ? pt.msgGap / 2 : 3, left: 12, right: 12, ), child: line, ), )); if (widget.readEventIds.contains(event.eventId)) { rows.add(_ReadMarker(pt: pt)); } if (widget.fullyReadId == event.eventId && !isLast) { rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading)); } } if (widget.showStatusIndicator && isOwn && widget.room != null && !widget.readEventIds.contains(widget.events.last.eventId)) { rows.add(Padding( padding: const EdgeInsets.only(right: 16, bottom: 1), child: Align( alignment: Alignment.centerRight, child: _MessageStatus(lastEvent: widget.events.last, pt: pt), ), )); } rows.add(const SizedBox(height: 2)); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: rows, ); }); } } /// Eine einzelne Sprechblase (bzw. bei Bildern/Videos: das Medium ohne Blase). class _Bubble extends StatelessWidget { final Event event; final PyramidTheme pt; final bool isOwn; final bool isFirst; final bool isLast; final String? senderName; final Color senderColor; final double maxWidth; final Timeline? timeline; const _Bubble({ required this.event, required this.pt, required this.isOwn, required this.isFirst, required this.isLast, required this.senderName, required this.senderColor, required this.maxWidth, this.timeline, }); Event get _displayEvent => timeline != null ? event.getDisplayEvent(timeline!) : event; bool get _isVisualMedia { final e = _displayEvent; if (e.type == EventTypes.Sticker) return true; return e.type == EventTypes.Message && (e.messageType == MessageTypes.Image || e.messageType == MessageTypes.Video); } /// Eigene Blase: Akzentfarbe, mit der Fläche verrechnet (bleibt lesbar). static Color ownColor(PyramidTheme pt) => Color.alphaBlend( pt.accent.withValues(alpha: pt.isDark ? 0.30 : 0.24), pt.bg1, ); BorderRadius get _radius { const big = Radius.circular(18); const small = Radius.circular(6); // Aufeinanderfolgende Blasen „kleben“ auf der Absenderseite zusammen. final topSide = isFirst ? big : small; final bottomSide = isLast ? big : small; return isOwn ? BorderRadius.only( topLeft: big, bottomLeft: big, topRight: topSide, bottomRight: bottomSide) : BorderRadius.only( topRight: big, bottomRight: big, topLeft: topSide, bottomLeft: bottomSide); } @override Widget build(BuildContext context) { final time = Text( _formatMessageTime(event.originServerTs), style: TextStyle(color: pt.fgDim, fontSize: 11), ); final failed = event.status.isError ? _SendFailedRow(event: event, pt: pt) : null; if (_isVisualMedia) { // Ohne Blase: Bilder/Videos sollen so groß wie möglich sein. final box = _reservedImageSize( _displayEvent, maxW: maxWidth.clamp(80.0, 420.0), maxH: inlineMediaMaxHeight(context), ); return Column( crossAxisAlignment: isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (senderName != null) _senderLabel(), SizedBox( width: box?.width ?? maxWidth, child: _MessageContent(event: event, pt: pt, timeline: timeline), ), ?failed, Padding(padding: const EdgeInsets.only(top: 2), child: time), ], ); } return ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), child: Container( padding: const EdgeInsets.fromLTRB(12, 7, 12, 6), decoration: BoxDecoration( color: isOwn ? ownColor(pt) : pt.bg3, borderRadius: _radius, ), // Außen „end“: Inhalt (breitestes Kind) füllt die Blase, die Uhrzeit // sitzt unten rechts – ohne die Blase auf volle Breite zu ziehen. child: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (senderName != null) _senderLabel(), _MessageContent(event: event, pt: pt, timeline: timeline), ?failed, ], ), const SizedBox(height: 2), time, ], ), ), ); } Widget _senderLabel() => Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( senderName!, style: TextStyle( color: senderColor, fontSize: 13, fontWeight: FontWeight.w600, ), ), ); }