Files
pyramid/lib/features/chat/message/message_bubbles.dart
T
Bernd SteckmeisterandClaude Opus 5.5 c5f10901e6 feat: UI-Umbau - weiches Dunkel, Schriftgroessen-Regler, Sprechblasen umschaltbar, Einstellungen als Vollbild
Bernds Entscheidungen 2026-10-07:
- theme.dart: ThemeVariant (Dunkel = neues weiches Dunkelgrau als Standard,
  Schwarz = bisheriges Fast-Schwarz, Hell) mit PyramidPalette; Nebentexte
  kontrastreicher. Alte Einstellung theme_dark wird uebernommen
  (dunkel -> weiches Dunkel). Fest verdrahtete Dunkelfarben im Profil-Popup
  und GIF-Picker folgen jetzt dem Farbmodus.
- appearance.dart: Schriftgroesse = Pyramid-Regler x Systemeinstellung
  (Handy-Standard 115 %, PC 100 %), MessageStyle Automatisch/Sprechblasen/
  Liste, gemeinsame isMobileLayout-Regel.
- message_bubbles.dart: Sprechblasen-Darstellung (eigene rechts in
  Akzentfarbe, Gruppen mit Name + Avatar, Bilder ohne Blase in voller Groesse);
  Listen-Darstellung unveraendert, Weiche in MessageGroup.
- Formatierte Nachrichten (u. a. Antworten) nutzten RichText ohne
  Schriftskalierung -> erschienen kleiner; jetzt Text.rich.
- Einstellungen auf dem Handy als ganze Seite, Schalter unter dem Text.
- UI-Durchlauf um Liste/Hell/Gruppe/Einstellungen erweitert; Tests fuer
  Theme-Varianten, Uebernahme alter Werte, Schriftfaktor, Darstellung.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:16:07 +02:00

262 lines
8.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = <Widget>[];
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(pt: pt));
}
}
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,
),
),
);
}