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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a1d64ec813
commit
c5f10901e6
@@ -0,0 +1,261 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user