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]>
262 lines
8.4 KiB
Dart
262 lines
8.4 KiB
Dart
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,
|
||
),
|
||
),
|
||
);
|
||
}
|