refactor: Gott-Datei chat_view.dart in 6 Dateien aufgeteilt
Fünfter und letzter Durchgang der part/part-of-Aufteilung - reine Verschiebung, keine Logikänderung. chat_view.dart schrumpft von 2116 auf 33 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze. Damit sind alle 5 in der M2-Ist-Analyse gefundenen Gott-Dateien aufgeteilt; ROADMAP-Punkt "Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen" abgehakt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
part of '../chat_view.dart';
|
||||
|
||||
class _MessageList extends StatelessWidget {
|
||||
final Timeline timeline;
|
||||
final ScrollController scrollCtrl;
|
||||
final String currentUserId;
|
||||
final PyramidTheme pt;
|
||||
final ValueChanged<Event> onReply;
|
||||
final Set<String> selectedIds;
|
||||
final ValueChanged<String> onToggleSelect;
|
||||
final String? sessionFullyReadId;
|
||||
final bool sessionHasUnread;
|
||||
final bool unreadFading;
|
||||
/// Attached to the group-level _UnreadDivider so ensureVisible can scroll to it.
|
||||
final GlobalKey? unreadDividerKey;
|
||||
/// Jump-to-message target (from search): scroll to + highlight this event.
|
||||
final String? jumpEventId;
|
||||
final GlobalKey? jumpKey;
|
||||
final String? highlightEventId;
|
||||
final bool highlightOn;
|
||||
|
||||
const _MessageList({
|
||||
required this.timeline,
|
||||
required this.scrollCtrl,
|
||||
required this.currentUserId,
|
||||
required this.pt,
|
||||
required this.onReply,
|
||||
required this.selectedIds,
|
||||
required this.onToggleSelect,
|
||||
this.sessionFullyReadId,
|
||||
this.sessionHasUnread = false,
|
||||
this.unreadFading = false,
|
||||
this.unreadDividerKey,
|
||||
this.jumpEventId,
|
||||
this.jumpKey,
|
||||
this.highlightEventId,
|
||||
this.highlightOn = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Dedupe by eventId — during concurrent forward/backward pagination the
|
||||
// timeline can transiently contain the same event twice, which would
|
||||
// produce duplicate ListView keys and crash the sliver (indexOf assertion).
|
||||
final seenIds = <String>{};
|
||||
final events = timeline.events
|
||||
.where(_isVisible)
|
||||
.where((e) => seenIds.add(e.eventId))
|
||||
.toList();
|
||||
|
||||
if (events.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No messages yet',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final groups = _buildGroups(events);
|
||||
final hasUnread = sessionHasUnread;
|
||||
|
||||
// Resolve the effective fullyReadId: if the captured ID points to a
|
||||
// non-visible event (reaction, edit, state) walk the timeline forward
|
||||
// (toward older events) until we find the nearest visible one.
|
||||
String? fullyReadId = sessionFullyReadId;
|
||||
if (fullyReadId != null) {
|
||||
final inGroups = groups.any((g) => g.any((e) => e.eventId == fullyReadId));
|
||||
if (!inGroups) {
|
||||
final tlEvents = timeline.events; // newest-first
|
||||
final idx = tlEvents.indexWhere((e) => e.eventId == fullyReadId);
|
||||
if (idx >= 0) {
|
||||
fullyReadId = null;
|
||||
for (int i = idx; i < tlEvents.length; i++) {
|
||||
if (_isVisible(tlEvents[i])) {
|
||||
fullyReadId = tlEvents[i].eventId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fullyReadId = null; // event not even in loaded timeline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read-receipt display — matching Element behaviour:
|
||||
// • The newest unread own group shows ✓ (sent but not read)
|
||||
// • The newest own group AT or BEFORE the partner's receipt shows ✓✓
|
||||
// • If those are the same group → just ✓✓
|
||||
final room = timeline.room;
|
||||
|
||||
// Read receipts (Element-style):
|
||||
// • ✓✓ at the single newest event another member has read (any sender) —
|
||||
// moves next to the partner's own reply when they reply/read everything.
|
||||
// • grey ✓ only on my newest message while it's still unread by them.
|
||||
// timeline.events is newest-first → lower index = newer.
|
||||
//
|
||||
// Receipts können je nach Client in `global` ODER `mainThread` stehen
|
||||
// (threaded read receipts) — beide zusammenführen, neuere ts gewinnt.
|
||||
final mergedReceipts = <String, LatestReceiptStateData>{};
|
||||
for (final source in [
|
||||
room.receiptState.global.otherUsers,
|
||||
room.receiptState.mainThread?.otherUsers ?? const {},
|
||||
]) {
|
||||
source.forEach((userId, data) {
|
||||
final existing = mergedReceipts[userId];
|
||||
if (existing == null || data.ts > existing.ts) {
|
||||
mergedReceipts[userId] = data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
int readIndex = -1;
|
||||
int latestReceiptTs = 0;
|
||||
mergedReceipts.forEach((userId, data) {
|
||||
if (userId == currentUserId) return;
|
||||
if (data.ts > latestReceiptTs) latestReceiptTs = data.ts;
|
||||
var ri = timeline.events.indexWhere((e) => e.eventId == data.eventId);
|
||||
// Receipt kann auf ein unsichtbares Event zeigen (Reaktion, Edit) —
|
||||
// zum nächstälteren sichtbaren Event vorrücken, sonst geht der Marker
|
||||
// verloren.
|
||||
while (ri >= 0 &&
|
||||
ri < timeline.events.length &&
|
||||
!_isVisible(timeline.events[ri])) {
|
||||
ri++;
|
||||
}
|
||||
if (ri >= timeline.events.length) ri = -1;
|
||||
if (ri >= 0 && (readIndex < 0 || ri < readIndex)) readIndex = ri;
|
||||
});
|
||||
final readEventIds =
|
||||
readIndex >= 0 ? {timeline.events[readIndex].eventId} : <String>{};
|
||||
|
||||
// Newest own visible message, and whether it's still unread (newer than the
|
||||
// partner's read position, or no read position yet).
|
||||
final newestOwnIndex =
|
||||
timeline.events.indexWhere((e) => _isVisible(e) && e.senderId == currentUserId);
|
||||
var ownUnread = newestOwnIndex >= 0 && (readIndex < 0 || newestOwnIndex < readIndex);
|
||||
// Fallback: Das Receipt-Event liegt außerhalb des geladenen Fensters
|
||||
// (z.B. sehr alte Nachricht) — wenn der Receipt-Zeitstempel jünger ist als
|
||||
// meine neueste Nachricht, gilt sie trotzdem als gelesen.
|
||||
if (ownUnread &&
|
||||
readIndex < 0 &&
|
||||
latestReceiptTs >=
|
||||
timeline.events[newestOwnIndex].originServerTs.millisecondsSinceEpoch) {
|
||||
readEventIds.add(timeline.events[newestOwnIndex].eventId);
|
||||
ownUnread = false;
|
||||
}
|
||||
final lastOwnGroupKey = (ownUnread)
|
||||
? () {
|
||||
for (final g in groups) {
|
||||
if (g.first.senderId == currentUserId) return 'g:${g.first.eventId}';
|
||||
}
|
||||
return null;
|
||||
}()
|
||||
: null;
|
||||
|
||||
// Flatten groups + date dividers + unread divider into independent list items.
|
||||
// itemFullyReadIds[i] is non-null only for a group item that contains the
|
||||
// fullyRead event at a non-last position — MessageGroup renders an inline
|
||||
// divider after that event.
|
||||
final itemKeys = <String>[];
|
||||
final itemGroups = <List<Event>?>[];
|
||||
final itemDates = <DateTime?>[];
|
||||
final itemUnread = <bool>[];
|
||||
final itemFullyReadIds = <String?>[];
|
||||
|
||||
bool unreadInserted = false;
|
||||
|
||||
for (var i = 0; i < groups.length; i++) {
|
||||
final group = groups[i];
|
||||
// Use the newest event (group.last after reversal) for date comparison.
|
||||
final groupNewest = group.last.originServerTs;
|
||||
final nextGroupNewest = i < groups.length - 1 ? groups[i + 1].last.originServerTs : null;
|
||||
final isLastOfDay = nextGroupNewest == null || !_sameDay(groupNewest, nextGroupNewest);
|
||||
|
||||
// Check if this group contains the effective fullyRead event.
|
||||
final groupContainsFullyRead = fullyReadId != null &&
|
||||
group.any((e) => e.eventId == fullyReadId);
|
||||
|
||||
if (!unreadInserted && hasUnread && groupContainsFullyRead) {
|
||||
if (group.last.eventId == fullyReadId) {
|
||||
// fullyRead is the NEWEST event in the group → the whole group is
|
||||
// read. Insert a group-level divider between this group and the
|
||||
// newer (lower-index) groups.
|
||||
itemKeys.add('unread');
|
||||
itemGroups.add(null);
|
||||
itemDates.add(null);
|
||||
itemUnread.add(true);
|
||||
itemFullyReadIds.add(null);
|
||||
}
|
||||
// Whether group-level or inline, we've now handled the marker.
|
||||
unreadInserted = true;
|
||||
}
|
||||
|
||||
// Key based on oldest event (group.first) — stable identifier.
|
||||
itemKeys.add('g:${group.first.eventId}');
|
||||
itemGroups.add(group);
|
||||
itemDates.add(null);
|
||||
itemUnread.add(false);
|
||||
// Pass fullyReadId to the group only when the event is mid-group
|
||||
// (not the last event) so MessageGroup can render an inline divider.
|
||||
// groupContainsFullyRead already guarantees fullyReadId != null.
|
||||
final passedFullyReadId =
|
||||
(groupContainsFullyRead && group.last.eventId != fullyReadId)
|
||||
? fullyReadId
|
||||
: null;
|
||||
itemFullyReadIds.add(passedFullyReadId);
|
||||
|
||||
if (isLastOfDay) {
|
||||
final d = groupNewest;
|
||||
itemKeys.add('d:${d.year}-${d.month}-${d.day}');
|
||||
itemGroups.add(null);
|
||||
itemDates.add(d);
|
||||
itemUnread.add(false);
|
||||
itemFullyReadIds.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: fullyRead event is older than all loaded events — all visible
|
||||
// messages are unread. Insert divider above the oldest loaded group (top of list).
|
||||
if (!unreadInserted && hasUnread) {
|
||||
itemKeys.add('unread');
|
||||
itemGroups.add(null);
|
||||
itemDates.add(null);
|
||||
itemUnread.add(true);
|
||||
itemFullyReadIds.add(null);
|
||||
}
|
||||
|
||||
final keyIndex = <String, int>{
|
||||
for (var i = 0; i < itemKeys.length; i++) itemKeys[i]: i,
|
||||
};
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollCtrl,
|
||||
reverse: true,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
|
||||
itemCount: itemKeys.length,
|
||||
findChildIndexCallback: (Key key) {
|
||||
// Only return an index when the key still maps to that exact slot —
|
||||
// returning a stale/duplicate index trips the sliver indexOf assertion.
|
||||
if (key is ValueKey<String>) {
|
||||
final idx = keyIndex[key.value];
|
||||
if (idx != null && idx < itemKeys.length && itemKeys[idx] == key.value) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
itemBuilder: (context, i) {
|
||||
final key = ValueKey(itemKeys[i]);
|
||||
if (itemUnread[i]) {
|
||||
// Wrap in SizedBox so the list can use the ValueKey for optimisation
|
||||
// while _UnreadDivider carries the GlobalKey for ensureVisible.
|
||||
return SizedBox(
|
||||
key: key,
|
||||
child: _UnreadDivider(key: unreadDividerKey, pt: pt, fading: unreadFading),
|
||||
);
|
||||
}
|
||||
final date = itemDates[i];
|
||||
if (date != null) return DateDivider(key: key, date: date);
|
||||
final group = itemGroups[i]!;
|
||||
final groupKey = itemKeys[i];
|
||||
// Show the sending/sent indicator on the newest own group.
|
||||
final showStatus = groupKey == lastOwnGroupKey;
|
||||
return MessageGroup(
|
||||
key: key,
|
||||
events: group,
|
||||
currentUserId: currentUserId,
|
||||
room: room,
|
||||
timeline: timeline,
|
||||
onReply: () => onReply(group.last),
|
||||
selectedIds: selectedIds,
|
||||
onToggleSelect: onToggleSelect,
|
||||
showStatusIndicator: showStatus,
|
||||
readEventIds: readEventIds,
|
||||
fullyReadId: itemFullyReadIds[i],
|
||||
jumpEventId: jumpEventId,
|
||||
jumpKey: jumpKey,
|
||||
highlightEventId: highlightEventId,
|
||||
highlightOn: highlightOn,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<List<Event>> _buildGroups(List<Event> events) {
|
||||
final groups = <List<Event>>[];
|
||||
List<Event>? current;
|
||||
|
||||
for (final event in events) {
|
||||
if (current == null ||
|
||||
current.first.senderId != event.senderId ||
|
||||
event.originServerTs.difference(current.last.originServerTs).abs() >
|
||||
const Duration(minutes: 5)) {
|
||||
current = [event];
|
||||
groups.add(current);
|
||||
} else {
|
||||
current.add(event);
|
||||
}
|
||||
}
|
||||
// The timeline is newest-first, so each group is [newest, ..., oldest].
|
||||
// Reverse each group so messages render oldest-to-newest (top to bottom).
|
||||
return groups.map((g) => g.reversed.toList()).toList();
|
||||
}
|
||||
|
||||
bool _sameDay(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
static bool _isVisible(Event e) {
|
||||
// NOTE: error-status events are kept visible so a failed send shows a
|
||||
// "not sent" state with retry/delete instead of silently disappearing.
|
||||
if (!{
|
||||
EventTypes.Message,
|
||||
EventTypes.Sticker,
|
||||
EventTypes.Encrypted,
|
||||
}.contains(e.type)) { return false; }
|
||||
if ({
|
||||
RelationshipTypes.edit,
|
||||
RelationshipTypes.reaction,
|
||||
}.contains(e.relationshipType)) { return false; }
|
||||
if (e.type == EventTypes.Redaction) { return false; }
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionHeader extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final int count;
|
||||
final Set<String> selectedIds;
|
||||
final Room? room;
|
||||
final Timeline? timeline;
|
||||
final String currentUserId;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback onReply;
|
||||
|
||||
const _SelectionHeader({
|
||||
required this.pt,
|
||||
required this.count,
|
||||
required this.selectedIds,
|
||||
required this.room,
|
||||
required this.timeline,
|
||||
required this.currentUserId,
|
||||
required this.onClear,
|
||||
required this.onReply,
|
||||
});
|
||||
|
||||
List<Event> _resolveEvents() {
|
||||
if (timeline == null) return [];
|
||||
return selectedIds
|
||||
.map((id) {
|
||||
try { return timeline!.events.firstWhere((e) => e.eventId == id); } catch (_) { return null; }
|
||||
})
|
||||
.whereType<Event>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _copyAll() {
|
||||
final texts = _resolveEvents().map((e) => e.body).join('\n');
|
||||
Clipboard.setData(ClipboardData(text: texts));
|
||||
}
|
||||
|
||||
void _deleteAll(BuildContext context) {
|
||||
for (final e in _resolveEvents()) {
|
||||
if (e.senderId == currentUserId) {
|
||||
e.redactEvent(reason: 'Vom Nutzer gelöscht').catchError((_) => null);
|
||||
}
|
||||
}
|
||||
onClear();
|
||||
}
|
||||
|
||||
void _pinAll() {
|
||||
if (room == null) return;
|
||||
final pinned = List<String>.from(
|
||||
room!.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
|
||||
);
|
||||
for (final e in _resolveEvents()) {
|
||||
if (!pinned.contains(e.eventId)) pinned.add(e.eventId);
|
||||
}
|
||||
room!.setPinnedEvents(pinned).catchError((_) => '');
|
||||
onClear();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final events = _resolveEvents();
|
||||
final canDelete = events.isNotEmpty && events.every((e) => e.senderId == currentUserId);
|
||||
final isTextOnly = events.every((e) => e.messageType == 'm.text');
|
||||
|
||||
return Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accentSoft,
|
||||
border: Border(bottom: BorderSide(color: pt.accent.withAlpha(80))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onClear,
|
||||
icon: Icon(Icons.close_rounded, size: 20, color: pt.accent),
|
||||
tooltip: 'Auswahl aufheben',
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$count ausgewählt',
|
||||
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Spacer(),
|
||||
if (count == 1)
|
||||
_SelBtn(icon: Icons.reply_rounded, tooltip: 'Antworten', pt: pt, onTap: onReply),
|
||||
if (isTextOnly)
|
||||
_SelBtn(icon: Icons.copy_rounded, tooltip: 'Kopieren', pt: pt, onTap: _copyAll),
|
||||
_SelBtn(icon: Icons.push_pin_outlined, tooltip: 'Anpinnen', pt: pt, onTap: _pinAll),
|
||||
if (canDelete)
|
||||
_SelBtn(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
tooltip: 'Löschen',
|
||||
pt: pt,
|
||||
color: pt.danger,
|
||||
onTap: () => _deleteAll(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelBtn extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final PyramidTheme pt;
|
||||
final Color? color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SelBtn({required this.icon, required this.tooltip, required this.pt, required this.onTap, this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
onPressed: onTap,
|
||||
icon: Icon(icon, size: 20, color: color ?? pt.fg),
|
||||
tooltip: tooltip,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: EdgeInsets.zero,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnreadDivider extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final bool fading;
|
||||
const _UnreadDivider({super.key, required this.pt, this.fading = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedOpacity(
|
||||
opacity: fading ? 0.0 : 1.0,
|
||||
duration: const Duration(milliseconds: 700),
|
||||
curve: Curves.easeOut,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
'Neue Nachrichten',
|
||||
style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user