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:
@@ -1167,3 +1167,5 @@
|
|||||||
2026-07-03 11:00 [Edit] /home/steggi/pyramid/PROGRESS.md
|
2026-07-03 11:00 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
2026-07-03 11:01 [Edit] /home/steggi/pyramid/ROADMAP.md
|
2026-07-03 11:01 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
2026-07-03 11:01 [Edit] /home/steggi/pyramid/PROGRESS.md
|
2026-07-03 11:01 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
2026-07-03 11:03 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||||
|
2026-07-03 11:03 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||||
|
|||||||
+58
@@ -13,6 +13,64 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-03 – M2: Gott-Datei `chat_view.dart` aufgeteilt (6 Dateien) – ROADMAP-Punkt abgehakt
|
||||||
|
|
||||||
|
**Erledigt:** Fünfter und letzter Durchgang derselben `part`/`part of`-Technik:
|
||||||
|
`features/chat/chat_view.dart` (2116 Zeilen, 22 Klassen, keine losen
|
||||||
|
Top-Level-Helfer) aufgeteilt in 6 Dateien unter `features/chat/view/`:
|
||||||
|
`chat_view_core.dart` (`ChatView`, `_ChatViewState` – der komplette
|
||||||
|
Nachrichten-Senden/Scroll/Attachment-Zustandsautomat, mit 521 Zeilen
|
||||||
|
weiterhin der größte Einzelblock, aber nicht weiter unterteilt, da es EINE
|
||||||
|
zusammenhängende State-Klasse ist, keine künstliche Aufspaltung mitten in
|
||||||
|
einer Klasse), `chat_header.dart` (`_ChatHeader`, DM-Header inkl.
|
||||||
|
`_DmExpandingHeader`+State, `_DmHeaderAvatar`, `_InitialCircle`,
|
||||||
|
`_PresenceLine`+State), `chat_connection.dart` (`_ConnectionBanner`+State,
|
||||||
|
Offline/Reconnect-Banner), `chat_message_list.dart` (`_MessageList`,
|
||||||
|
`_SelectionHeader`, `_SelBtn`, `_UnreadDivider`), `chat_typing.dart`
|
||||||
|
(`_TypingIndicator`+State, `_TypingDots`+State), `chat_misc.dart`
|
||||||
|
(`_DragOverlay`+State für Datei-Drag&Drop, `NoChatSelected` – bleibt
|
||||||
|
öffentlich, `app_shell.dart` importiert `chat_view.dart` und nutzt es).
|
||||||
|
`chat_view.dart` selbst: nur noch Bibliothekskopf (33 Zeilen).
|
||||||
|
|
||||||
|
Verifikation identisch zu den vier vorigen Umbauten: automatisierte
|
||||||
|
Klammer-Zählung, alle 22 Blöcke wortwörtlich wiedergefunden,
|
||||||
|
`class`/`enum`-Anzahl (22) und `Widget build(`-Methoden (15) vorher/nachher
|
||||||
|
identisch, `flutter analyze` unverändert bei 1 bekanntem Hinweis.
|
||||||
|
|
||||||
|
**Damit sind alle 5 in der M2-Ist-Analyse gefundenen Gott-Dateien aufgeteilt**
|
||||||
|
(`settings_modal.dart`, `message_group.dart`, `rooms_panel.dart`,
|
||||||
|
`space_admin_dialog.dart`, `chat_view.dart` – siehe die vier Einträge
|
||||||
|
darunter). ROADMAP-Punkt „Toten Code & Duplikate entfernen, Ordnerstruktur
|
||||||
|
vereinheitlichen" jetzt abgehakt.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** **Wichtig, bevor der nächste ROADMAP-Punkt
|
||||||
|
angefasst wird:** all diese Aufteilungen sind bisher NUR per `flutter
|
||||||
|
analyze` + automatisiertem Textvergleich geprüft, NICHT in echter UI
|
||||||
|
gesehen (kein GUI-Toolchain auf dem Pi verfügbar – kein Android-SDK, kein
|
||||||
|
Linux-Desktop-Build, siehe `flutter doctor`). Der nächste PC-/Windows-Lauf
|
||||||
|
MUSS vor dem nächsten inhaltlichen Schritt einmal durch alle 5 betroffenen
|
||||||
|
Bereiche klicken (Einstellungen komplett, ein Chat mit allen
|
||||||
|
Nachrichtentypen, die Raumliste, eine Space-Verwaltung, ein normaler
|
||||||
|
Chat-Screen mit Header/Tippanzeige/Drag&Drop) – das Risiko einer reinen
|
||||||
|
`part`/`part-of`-Verschiebung ist strukturell gering (Dart-Compiler hätte
|
||||||
|
jeden fehlenden Import/jede falsche Sichtbarkeit sofort als Analyzer-Fehler
|
||||||
|
gemeldet, nicht erst zur Laufzeit), aber „gering" ist nicht „null", und
|
||||||
|
CLAUDE.md verlangt nach jedem Refactoring-Schritt einen echten App-Start.
|
||||||
|
Danach nächster ROADMAP-Punkt in M2: „Call-Schicht entwirren" – dafür
|
||||||
|
weiterhin Bernds Entscheidung zum Call-Pilot aus „M2: Modul-Schnitt
|
||||||
|
definiert" ausstehend, UND dieser Punkt braucht ohnehin einen PC/Windows-Test
|
||||||
|
(Verhaltensänderung an State-Management, nicht nur Datei-Verschiebung).
|
||||||
|
|
||||||
|
**Stolperfallen:** Keine neuen. Generelle Lehre aus allen 5 Durchgängen:
|
||||||
|
Skript-gestützte Klammer-Zählung + automatisierter Verbatim-Abgleich
|
||||||
|
(„jeder Originalblock muss wortwörtlich in den neuen Dateien auftauchen")
|
||||||
|
war der entscheidende Sicherheitsnetz-Schritt, um trotz fehlendem GUI-Test
|
||||||
|
auf dem Pi mit vertretbarem Risiko fortzufahren – sollte bei künftigen
|
||||||
|
reinen Datei-Umbauten (z. B. `voice_channel.dart` beim Call-Pilot) als
|
||||||
|
Vorgehen wiederverwendet werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-03 – M2: Gott-Datei `space_admin_dialog.dart` aufgeteilt (6 Dateien)
|
## 2026-07-03 – M2: Gott-Datei `space_admin_dialog.dart` aufgeteilt (6 Dateien)
|
||||||
|
|
||||||
**Erledigt:** Vierter Durchgang derselben `part`/`part of`-Technik:
|
**Erledigt:** Vierter Durchgang derselben `part`/`part of`-Technik:
|
||||||
|
|||||||
+17
-14
@@ -75,20 +75,23 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
|
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
|
||||||
LiveKit-Verbindung / UI-State sauber getrennt)
|
LiveKit-Verbindung / UI-State sauber getrennt)
|
||||||
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
||||||
- [ ] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
- [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
||||||
(angefangen, PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt,
|
(PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte
|
||||||
doppelte `_formatTime` in `message_group.dart` zusammengeführt;
|
`_formatTime` in `message_group.dart` zusammengeführt; alle 5 in der
|
||||||
Gott-Datei `widgets/settings_modal.dart` (5541 Zeilen) in 12
|
Ist-Analyse gefundenen Gott-Dateien per `part`/`part of` in
|
||||||
`part`-Dateien unter `widgets/settings/` aufgeteilt, siehe PROGRESS.md
|
thematische Dateien aufgeteilt – `widgets/settings_modal.dart`
|
||||||
2026-07-03 „Settings-Datei aufgeteilt"; `features/chat/message_group.dart`
|
(5541→270 Zeilen, 12 Teile unter `widgets/settings/`),
|
||||||
(2775 Zeilen) ebenso in 7 `part`-Dateien unter `features/chat/message/`
|
`features/chat/message_group.dart` (2775→30 Zeilen, 7 Teile unter
|
||||||
aufgeteilt (PROGRESS.md 2026-07-03 „message_group.dart aufgeteilt");
|
`features/chat/message/`), `features/rooms/rooms_panel.dart`
|
||||||
`features/rooms/rooms_panel.dart` (2555 Zeilen) ebenso in 8
|
(2555→31 Zeilen, 8 Teile unter `features/rooms/panel/`),
|
||||||
`part`-Dateien unter `features/rooms/panel/` aufgeteilt (PROGRESS.md
|
`features/spaces/space_admin_dialog.dart` (2278→19 Zeilen, 6 Teile
|
||||||
2026-07-03 „rooms_panel.dart aufgeteilt"); `features/spaces/space_admin_dialog.dart`
|
unter `features/spaces/admin/`), `features/chat/chat_view.dart`
|
||||||
(2278 Zeilen) ebenso in 6 `part`-Dateien unter `features/spaces/admin/`
|
(2116→33 Zeilen, 6 Teile unter `features/chat/view/`) – jeweils
|
||||||
aufgeteilt (PROGRESS.md 2026-07-03 „space_admin_dialog.dart aufgeteilt")
|
verifiziert per automatisiertem Blockvergleich gegen das Original und
|
||||||
– restliche Gott-Datei `chat_view.dart` aus der Ist-Analyse noch offen)
|
`flutter analyze`, siehe PROGRESS.md-Einträge. UNGETESTET (Pi) im
|
||||||
|
Sinne von „nicht in echter UI gesehen" – nächster Windows-Lauf sollte
|
||||||
|
alle 5 betroffenen Bereiche einmal durchklicken, siehe „Fragen an
|
||||||
|
Bernd"/Nächster-Schritt in den jeweiligen PROGRESS.md-Einträgen)
|
||||||
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
||||||
|
|
||||||
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
|||||||
|
part of '../chat_view.dart';
|
||||||
|
|
||||||
|
class _ConnectionBanner extends ConsumerStatefulWidget {
|
||||||
|
const _ConnectionBanner();
|
||||||
|
@override
|
||||||
|
ConsumerState<_ConnectionBanner> createState() => _ConnectionBannerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConnectionBannerState extends ConsumerState<_ConnectionBanner> {
|
||||||
|
StreamSubscription? _sub;
|
||||||
|
Timer? _debounce;
|
||||||
|
bool _lost = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_attach();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _attach() async {
|
||||||
|
try {
|
||||||
|
final client = await ref.read(matrixClientProvider.future);
|
||||||
|
_sub = client.onSyncStatus.stream.listen((s) {
|
||||||
|
if (s.status == SyncStatus.error) {
|
||||||
|
// Only surface after the error persists a few seconds.
|
||||||
|
_debounce ??= Timer(const Duration(seconds: 4), () {
|
||||||
|
_debounce = null;
|
||||||
|
if (mounted) setState(() => _lost = true);
|
||||||
|
});
|
||||||
|
} else if (s.status == SyncStatus.finished ||
|
||||||
|
s.status == SyncStatus.processing) {
|
||||||
|
_debounce?.cancel();
|
||||||
|
_debounce = null;
|
||||||
|
if (_lost && mounted) setState(() => _lost = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sub?.cancel();
|
||||||
|
_debounce?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
return AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: _lost
|
||||||
|
? Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: pt.danger.withAlpha(36),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.cloud_off_rounded, size: 14, color: pt.danger),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'Keine Verbindung zum Server – Wiederverbindung läuft…',
|
||||||
|
style: TextStyle(color: pt.danger, fontSize: 12, fontWeight: FontWeight.w500),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,732 @@
|
|||||||
|
part of '../chat_view.dart';
|
||||||
|
|
||||||
|
class _ChatHeader extends ConsumerWidget {
|
||||||
|
final Room? room;
|
||||||
|
final String roomId;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final bool membersOpen;
|
||||||
|
final bool pinnedOpen;
|
||||||
|
final VoidCallback onToggleMembers;
|
||||||
|
final VoidCallback onOpenSearch;
|
||||||
|
final VoidCallback onTogglePinned;
|
||||||
|
|
||||||
|
const _ChatHeader({
|
||||||
|
required this.room,
|
||||||
|
required this.roomId,
|
||||||
|
required this.pt,
|
||||||
|
required this.membersOpen,
|
||||||
|
required this.pinnedOpen,
|
||||||
|
required this.onToggleMembers,
|
||||||
|
required this.onOpenSearch,
|
||||||
|
required this.onTogglePinned,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final name = room?.getLocalizedDisplayname() ?? roomId;
|
||||||
|
final isDm = room?.isDirectChat ?? false;
|
||||||
|
final isEncrypted = room?.encrypted ?? false;
|
||||||
|
final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name');
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final isMobile = size.width < 900 || size.height < 500;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 52,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg1,
|
||||||
|
border: Border(bottom: BorderSide(color: pt.border)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Back button on mobile
|
||||||
|
if (isMobile) ...[
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => ref.read(activeRoomIdProvider.notifier).state = null,
|
||||||
|
child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
// Room icon / avatar
|
||||||
|
if (isDm && client != null) ...[
|
||||||
|
_DmHeaderAvatar(room: room, name: name, client: client),
|
||||||
|
] else if (!isDm) ...[
|
||||||
|
Icon(
|
||||||
|
isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: pt.fgDim,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// Name + presence for DMs
|
||||||
|
if (isDm)
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (room != null && client != null)
|
||||||
|
_PresenceLine(
|
||||||
|
userId: room!.directChatMatrixID,
|
||||||
|
client: client,
|
||||||
|
pt: pt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
if (!isDm && topic.isNotEmpty) ...[
|
||||||
|
Container(width: 1, height: 18, color: pt.border),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
topic,
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else if (!isDm)
|
||||||
|
const Spacer(),
|
||||||
|
// Right actions
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (isDm) ...[
|
||||||
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.phone_rounded),
|
||||||
|
tooltip: 'Voice call',
|
||||||
|
onPressed: () => _startCall(ref, room, cam: false),
|
||||||
|
),
|
||||||
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.videocam_rounded),
|
||||||
|
tooltip: 'Video call',
|
||||||
|
onPressed: () => _startCall(ref, room, cam: true),
|
||||||
|
),
|
||||||
|
if (size.width > 700)
|
||||||
|
Container(
|
||||||
|
width: 1,
|
||||||
|
height: 20,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
color: pt.border,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (size.width > 800) ...[
|
||||||
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.inbox_rounded),
|
||||||
|
tooltip: 'Threads',
|
||||||
|
onPressed: () {},
|
||||||
|
),
|
||||||
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.push_pin_rounded),
|
||||||
|
tooltip: 'Angepinnte Nachrichten',
|
||||||
|
active: pinnedOpen,
|
||||||
|
onPressed: onTogglePinned,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.search_rounded),
|
||||||
|
tooltip: 'Search',
|
||||||
|
onPressed: onOpenSearch,
|
||||||
|
),
|
||||||
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.group_rounded),
|
||||||
|
tooltip: 'Members',
|
||||||
|
active: membersOpen,
|
||||||
|
onPressed: onToggleMembers,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startCall(WidgetRef ref, Room? room, {required bool cam}) async {
|
||||||
|
if (room == null) return;
|
||||||
|
|
||||||
|
if (room.isDirectChat) {
|
||||||
|
// Use native Matrix VoIP for 1:1 DMs
|
||||||
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
await PyramidVoipManager.instance.startCall(room.id, video: cam);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use LiveKit for group voice rooms
|
||||||
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
await ref.read(callStateProvider).startCall(
|
||||||
|
roomName: roomId,
|
||||||
|
roomDisplayName: room.getLocalizedDisplayname(),
|
||||||
|
identity: 'user_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
audioOnly: !cam,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DmExpandingHeader extends ConsumerStatefulWidget {
|
||||||
|
final Room room;
|
||||||
|
final String roomId;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final bool expanded;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final bool membersOpen;
|
||||||
|
final bool pinnedOpen;
|
||||||
|
final VoidCallback onToggleMembers;
|
||||||
|
final VoidCallback onOpenSearch;
|
||||||
|
final VoidCallback onTogglePinned;
|
||||||
|
|
||||||
|
const _DmExpandingHeader({
|
||||||
|
super.key,
|
||||||
|
required this.room,
|
||||||
|
required this.roomId,
|
||||||
|
required this.pt,
|
||||||
|
required this.expanded,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.membersOpen,
|
||||||
|
required this.pinnedOpen,
|
||||||
|
required this.onToggleMembers,
|
||||||
|
required this.onOpenSearch,
|
||||||
|
required this.onTogglePinned,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_DmExpandingHeader> createState() =>
|
||||||
|
_DmExpandingHeaderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
||||||
|
static const _animDuration = Duration(milliseconds: 280);
|
||||||
|
static const _animCurve = Curves.easeOutCubic;
|
||||||
|
|
||||||
|
Profile? _profile;
|
||||||
|
String? _bannerMxcUri;
|
||||||
|
String? _statusMsg;
|
||||||
|
bool _online = false;
|
||||||
|
|
||||||
|
String? get _partnerId => widget.room.directChatMatrixID;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Direkt laden, damit das Banner beim ersten Aufklappen schon da ist.
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_DmExpandingHeader old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.room.id != widget.room.id) {
|
||||||
|
_profile = null;
|
||||||
|
_bannerMxcUri = null;
|
||||||
|
_statusMsg = null;
|
||||||
|
_online = false;
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final userId = _partnerId;
|
||||||
|
if (userId == null) return;
|
||||||
|
final client = widget.room.client;
|
||||||
|
// Race-Guard: Beim schnellen DM-Wechsel dürfen verspätete Antworten des
|
||||||
|
// VORHERIGEN Partners das Profil des aktuellen nicht überschreiben.
|
||||||
|
bool stillCurrent() => mounted && _partnerId == userId;
|
||||||
|
try {
|
||||||
|
final p = await client.getProfileFromUserId(userId);
|
||||||
|
if (stillCurrent()) setState(() => _profile = p);
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
final p = await client.fetchCurrentPresence(userId);
|
||||||
|
final last = p.lastActiveTimestamp;
|
||||||
|
final fresh = p.currentlyActive == true ||
|
||||||
|
(last != null &&
|
||||||
|
DateTime.now().difference(last) < const Duration(minutes: 5));
|
||||||
|
if (stillCurrent()) {
|
||||||
|
setState(() {
|
||||||
|
_online = p.presence == PresenceType.online && fresh;
|
||||||
|
_statusMsg = p.statusMsg;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
// Banner aus dem Profil-Custom-Field (io.pyramid.profile.banner_url).
|
||||||
|
try {
|
||||||
|
final apiUri = client.homeserver!.replace(
|
||||||
|
path: '/_matrix/client/v3/profile/${Uri.encodeComponent(userId)}',
|
||||||
|
);
|
||||||
|
final resp = await client.httpClient.get(
|
||||||
|
apiUri,
|
||||||
|
headers: {'Authorization': 'Bearer ${client.accessToken}'},
|
||||||
|
);
|
||||||
|
if (resp.statusCode == 200) {
|
||||||
|
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||||
|
final url = data['io.pyramid.profile.banner_url'] as String?;
|
||||||
|
if (url != null && stillCurrent()) {
|
||||||
|
setState(() => _bannerMxcUri = url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
final expanded = widget.expanded;
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final isMobile = size.width < 900 || size.height < 500;
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
|
||||||
|
final userId = _partnerId ?? '';
|
||||||
|
final member = widget.room.unsafeGetUserFromMemoryOrFallback(userId);
|
||||||
|
final name = _profile?.displayName ??
|
||||||
|
member.displayName ??
|
||||||
|
widget.room.getLocalizedDisplayname();
|
||||||
|
final avatarUrl =
|
||||||
|
_profile?.avatarUrl ?? member.avatarUrl ?? widget.room.avatar;
|
||||||
|
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
||||||
|
|
||||||
|
// Im Querformat (wenig Höhe) kompakter aufklappen.
|
||||||
|
final expandedHeight = size.height < 500 ? 140.0 : 190.0;
|
||||||
|
final avatarSize = expanded
|
||||||
|
? (size.height < 500 ? 72.0 : 96.0)
|
||||||
|
: 32.0;
|
||||||
|
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: _animDuration,
|
||||||
|
curve: _animCurve,
|
||||||
|
height: expanded ? expandedHeight : 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg1,
|
||||||
|
border: Border(bottom: BorderSide(color: pt.border)),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// Banner als Hintergrund — blendet beim Aufklappen ein.
|
||||||
|
if (_bannerMxcUri != null && client != null)
|
||||||
|
Positioned.fill(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: expanded ? 1 : 0,
|
||||||
|
duration: _animDuration,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: MxcImage(
|
||||||
|
mxcUri: _bannerMxcUri!,
|
||||||
|
client: client,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Scrim: links dunkel, damit Avatar/Text lesbar bleiben (Mockup).
|
||||||
|
if (_bannerMxcUri != null)
|
||||||
|
Positioned.fill(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: expanded ? 1 : 0,
|
||||||
|
duration: _animDuration,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: [
|
||||||
|
pt.bg1,
|
||||||
|
pt.bg1.withAlpha(210),
|
||||||
|
pt.bg1.withAlpha(0),
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.32, 0.72],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (isMobile) ...[
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () =>
|
||||||
|
ref.read(activeRoomIdProvider.notifier).state = null,
|
||||||
|
child:
|
||||||
|
Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
// Klickbarer Profilbereich: Avatar + Name + Details
|
||||||
|
Expanded(
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: widget.onToggle,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Avatar — skaliert mit (rund, wie im Mockup)
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: _animDuration,
|
||||||
|
curve: _animCurve,
|
||||||
|
width: avatarSize,
|
||||||
|
height: avatarSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: pt.bg3,
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: avatarUrl != null && client != null
|
||||||
|
? MxcImage(
|
||||||
|
mxcUri: avatarUrl.toString(),
|
||||||
|
client: client,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: _initialBox(initial, pt),
|
||||||
|
)
|
||||||
|
: _initialBox(initial, pt),
|
||||||
|
),
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: _animDuration,
|
||||||
|
curve: _animCurve,
|
||||||
|
width: expanded ? 20 : 10,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Name skaliert mit; Status blendet daneben ein
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: AnimatedDefaultTextStyle(
|
||||||
|
duration: _animDuration,
|
||||||
|
curve: _animCurve,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: expanded ? 24 : 15,
|
||||||
|
fontWeight: expanded
|
||||||
|
? FontWeight.w700
|
||||||
|
: FontWeight.w600,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedOpacity(
|
||||||
|
opacity: expanded ? 1 : 0,
|
||||||
|
duration: _animDuration,
|
||||||
|
child: Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.only(left: 8),
|
||||||
|
child: Text(
|
||||||
|
_online ? 'online' : 'offline',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _online
|
||||||
|
? pt.online
|
||||||
|
: pt.fgMuted,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Eingeklappt: "zuletzt online vor…",
|
||||||
|
// aufgeklappt: Handle + Biographie.
|
||||||
|
AnimatedCrossFade(
|
||||||
|
duration: _animDuration,
|
||||||
|
sizeCurve: _animCurve,
|
||||||
|
crossFadeState: expanded
|
||||||
|
? CrossFadeState.showSecond
|
||||||
|
: CrossFadeState.showFirst,
|
||||||
|
firstChild: client != null
|
||||||
|
? _PresenceLine(
|
||||||
|
userId: _partnerId,
|
||||||
|
client: client,
|
||||||
|
pt: pt,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
secondChild: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
userId,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fgMuted, fontSize: 13),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (_statusMsg != null &&
|
||||||
|
_statusMsg!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_statusMsg!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fgMuted,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.35),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Aktionen — bleiben immer sichtbar
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.phone_rounded),
|
||||||
|
tooltip: 'Voice call',
|
||||||
|
onPressed: () => _startCall(cam: false),
|
||||||
|
),
|
||||||
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.videocam_rounded),
|
||||||
|
tooltip: 'Video call',
|
||||||
|
onPressed: () => _startCall(cam: true),
|
||||||
|
),
|
||||||
|
if (size.width > 700)
|
||||||
|
Container(
|
||||||
|
width: 1,
|
||||||
|
height: 20,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
color: pt.border,
|
||||||
|
),
|
||||||
|
if (size.width > 800)
|
||||||
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.push_pin_rounded),
|
||||||
|
tooltip: 'Angepinnte Nachrichten',
|
||||||
|
active: widget.pinnedOpen,
|
||||||
|
onPressed: widget.onTogglePinned,
|
||||||
|
),
|
||||||
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.search_rounded),
|
||||||
|
tooltip: 'Search',
|
||||||
|
onPressed: widget.onOpenSearch,
|
||||||
|
),
|
||||||
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.group_rounded),
|
||||||
|
tooltip: 'Members',
|
||||||
|
active: widget.membersOpen,
|
||||||
|
onPressed: widget.onToggleMembers,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _initialBox(String initial, PyramidTheme pt) => Center(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: Text(
|
||||||
|
initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
void _startCall({required bool cam}) async {
|
||||||
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
await PyramidVoipManager.instance.startCall(widget.room.id, video: cam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DmHeaderAvatar extends StatelessWidget {
|
||||||
|
final Room? room;
|
||||||
|
final String name;
|
||||||
|
final Client client;
|
||||||
|
|
||||||
|
const _DmHeaderAvatar({required this.room, required this.name, required this.client});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
||||||
|
const size = 32.0;
|
||||||
|
return MxcAvatar(
|
||||||
|
mxcUri: room?.avatar,
|
||||||
|
client: client,
|
||||||
|
size: size,
|
||||||
|
borderRadius: BorderRadius.circular(size / 2),
|
||||||
|
placeholder: (_) => _InitialCircle(initial: initial, size: size),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InitialCircle extends StatelessWidget {
|
||||||
|
final String initial;
|
||||||
|
final double size;
|
||||||
|
const _InitialCircle({required this.initial, required this.size});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const color = Color(0xFF06B6D4);
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: const BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
child: Center(
|
||||||
|
child: Text(initial,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PresenceLine extends StatefulWidget {
|
||||||
|
final String? userId;
|
||||||
|
final Client client;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
|
||||||
|
const _PresenceLine({required this.userId, required this.client, required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PresenceLine> createState() => _PresenceLineState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PresenceLineState extends State<_PresenceLine> {
|
||||||
|
CachedPresence? _presence;
|
||||||
|
StreamSubscription<CachedPresence>? _sub;
|
||||||
|
// Debounce timer: delay "online" updates 4s to filter out background-sync
|
||||||
|
// false positives (push notifications briefly wake the recipient's client).
|
||||||
|
Timer? _onlineDebounce;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load(widget.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_PresenceLine oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.userId != widget.userId) {
|
||||||
|
_sub?.cancel();
|
||||||
|
_sub = null;
|
||||||
|
_onlineDebounce?.cancel();
|
||||||
|
setState(() => _presence = null);
|
||||||
|
_load(widget.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sub?.cancel();
|
||||||
|
_onlineDebounce?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// "online" only when the server's presence enum says online AND the last
|
||||||
|
// activity is recent. Using currentlyActive (the old logic) reported online
|
||||||
|
// long after the user left, because that flag lingers.
|
||||||
|
static bool _isOnline(CachedPresence p) {
|
||||||
|
if (p.presence != PresenceType.online) return false;
|
||||||
|
final last = p.lastActiveTimestamp;
|
||||||
|
return last == null || DateTime.now().difference(last).inSeconds < 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyPresence(CachedPresence p) {
|
||||||
|
_onlineDebounce?.cancel();
|
||||||
|
if (_isOnline(p)) {
|
||||||
|
// Wait 4 seconds before committing "online" to filter single-sync spikes
|
||||||
|
// (a backgrounded client briefly reports online when a push wakes it).
|
||||||
|
_onlineDebounce = Timer(const Duration(seconds: 4), () {
|
||||||
|
if (mounted) setState(() => _presence = p);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (mounted) setState(() => _presence = p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load(String? userId) async {
|
||||||
|
if (userId == null) return;
|
||||||
|
// Never show our own presence in a DM header
|
||||||
|
if (userId == widget.client.userID) return;
|
||||||
|
try {
|
||||||
|
final p = await widget.client.fetchCurrentPresence(userId);
|
||||||
|
if (mounted) _applyPresence(p);
|
||||||
|
} catch (_) {}
|
||||||
|
_sub = widget.client.onPresenceChanged.stream
|
||||||
|
.where((p) => p.userid == userId)
|
||||||
|
.listen((p) => _applyPresence(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = _presence;
|
||||||
|
if (p == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
String text;
|
||||||
|
Color color = widget.pt.fgDim;
|
||||||
|
if (_isOnline(p)) {
|
||||||
|
text = 'online';
|
||||||
|
color = const Color(0xFF3BA55D);
|
||||||
|
} else if (p.lastActiveTimestamp != null) {
|
||||||
|
text = 'zuletzt online ${_relativeTime(p.lastActiveTimestamp!)}';
|
||||||
|
} else {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(color: color, fontSize: 11),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _relativeTime(DateTime t) {
|
||||||
|
final diff = DateTime.now().difference(t);
|
||||||
|
if (diff.inMinutes < 1) return 'gerade eben';
|
||||||
|
if (diff.inMinutes < 60) return 'vor ${diff.inMinutes}min';
|
||||||
|
if (diff.inHours < 24) return 'vor ${diff.inHours}h';
|
||||||
|
if (diff.inDays < 7) return 'vor ${diff.inDays}d';
|
||||||
|
if (diff.inDays < 365) return 'vor ${(diff.inDays / 7).floor()}w';
|
||||||
|
return 'vor ${(diff.inDays / 365).floor()}j';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
part of '../chat_view.dart';
|
||||||
|
|
||||||
|
class _DragOverlay extends StatefulWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final bool active;
|
||||||
|
const _DragOverlay({required this.pt, required this.active});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DragOverlay> createState() => _DragOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DragOverlayState extends State<_DragOverlay>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
// Sanftes Auf-und-ab des Icons, solange das Overlay aktiv ist.
|
||||||
|
late final AnimationController _bob = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 900),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.active) _bob.repeat(reverse: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_DragOverlay old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (widget.active && !old.active) {
|
||||||
|
_bob.repeat(reverse: true);
|
||||||
|
} else if (!widget.active && old.active) {
|
||||||
|
_bob.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_bob.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
return Container(
|
||||||
|
color: pt.bg0.withAlpha(200),
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedScale(
|
||||||
|
scale: widget.active ? 1.0 : 0.88,
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg2,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: pt.accent, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: pt.accent.withAlpha(60),
|
||||||
|
blurRadius: 32,
|
||||||
|
spreadRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _bob,
|
||||||
|
builder: (_, child) => Transform.translate(
|
||||||
|
offset: Offset(
|
||||||
|
0, -7 * Curves.easeInOut.transform(_bob.value)),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
Icon(Icons.upload_file_rounded, size: 56, color: pt.accent),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Datei hier ablegen',
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Loslassen zum Hochladen',
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoChatSelected extends StatelessWidget {
|
||||||
|
const NoChatSelected({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
return Container(
|
||||||
|
color: pt.bg1,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.forum_outlined, size: 64, color: pt.fgDim),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Select a room to start chatting',
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Choose from the sidebar on the left',
|
||||||
|
style: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
part of '../chat_view.dart';
|
||||||
|
|
||||||
|
class _TypingIndicator extends StatefulWidget {
|
||||||
|
final Room room;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _TypingIndicator({required this.room, required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TypingIndicator> createState() => _TypingIndicatorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TypingIndicatorState extends State<_TypingIndicator> {
|
||||||
|
StreamSubscription? _sub;
|
||||||
|
List<User> _typing = [];
|
||||||
|
|
||||||
|
StreamSubscription _listenTyping(Room room) {
|
||||||
|
// client.onSync fires after every sync — cheap to filter for typing events.
|
||||||
|
return room.client.onSync.stream.listen((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final typing = room.typingUsers
|
||||||
|
.where((u) => u.id != room.client.userID)
|
||||||
|
.toList();
|
||||||
|
if (typing.length != _typing.length ||
|
||||||
|
!typing.every((u) => _typing.any((t) => t.id == u.id))) {
|
||||||
|
setState(() => _typing = typing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_sub = _listenTyping(widget.room);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_TypingIndicator oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.room.id != widget.room.id) {
|
||||||
|
_sub?.cancel();
|
||||||
|
setState(() => _typing = []);
|
||||||
|
_sub = _listenTyping(widget.room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_typing.isEmpty) return const SizedBox.shrink();
|
||||||
|
final pt = widget.pt;
|
||||||
|
final names = _typing.map((u) => u.calcDisplayname()).toList();
|
||||||
|
final label = names.length == 1
|
||||||
|
? '${names[0]} tippt…'
|
||||||
|
: names.length == 2
|
||||||
|
? '${names[0]} und ${names[1]} tippen…'
|
||||||
|
: '${names[0]} und ${names.length - 1} weitere tippen…';
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 2, 20, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_TypingDots(color: pt.fgDim),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(color: pt.fgDim, fontSize: 12, fontStyle: FontStyle.italic),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TypingDots extends StatefulWidget {
|
||||||
|
final Color color;
|
||||||
|
const _TypingDots({required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TypingDots> createState() => _TypingDotsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TypingDotsState extends State<_TypingDots>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _ctrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ctrl = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 900),
|
||||||
|
)..repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _ctrl,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: List.generate(3, (i) {
|
||||||
|
// Each dot peaks at a different phase.
|
||||||
|
final phase = ((_ctrl.value * 3) - i).clamp(0.0, 1.0);
|
||||||
|
final opacity = (phase < 0.5 ? phase * 2 : (1 - phase) * 2).clamp(0.3, 1.0);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||||
|
child: Opacity(
|
||||||
|
opacity: opacity,
|
||||||
|
child: Container(
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
part of '../chat_view.dart';
|
||||||
|
|
||||||
|
class ChatView extends ConsumerStatefulWidget {
|
||||||
|
final String roomId;
|
||||||
|
const ChatView({super.key, required this.roomId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ChatView> createState() => _ChatViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatViewState extends ConsumerState<ChatView> {
|
||||||
|
final _scrollCtrl = ScrollController();
|
||||||
|
Event? _replyTo;
|
||||||
|
bool _loadingHistory = false;
|
||||||
|
bool _historyExhausted = false;
|
||||||
|
bool _draggingOver = false;
|
||||||
|
bool _pinnedOpen = false;
|
||||||
|
bool _profileExpanded = false;
|
||||||
|
bool _showScrollDown = false;
|
||||||
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
|
// Unread state captured at session open (before marking as read)
|
||||||
|
String? _sessionFullyReadId;
|
||||||
|
bool _sessionHasUnread = false;
|
||||||
|
bool _sessionCaptured = false;
|
||||||
|
bool _didScrollToUnread = false;
|
||||||
|
bool _loadingFuture = false;
|
||||||
|
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
|
||||||
|
bool _unreadFading = false;
|
||||||
|
Timer? _unreadFadeTimer;
|
||||||
|
|
||||||
|
// Jump-to-message (from search): scroll to + briefly highlight a target event.
|
||||||
|
final _jumpKey = GlobalKey();
|
||||||
|
String? _jumpEventId;
|
||||||
|
String? _highlightEventId;
|
||||||
|
bool _highlightOn = false;
|
||||||
|
bool _jumpInProgress = false;
|
||||||
|
Timer? _highlightTimer;
|
||||||
|
|
||||||
|
void _startJump(String eventId) {
|
||||||
|
_highlightTimer?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_jumpEventId = eventId;
|
||||||
|
_highlightEventId = eventId;
|
||||||
|
_highlightOn = true;
|
||||||
|
_jumpInProgress = true;
|
||||||
|
});
|
||||||
|
// Consume the request so it doesn't re-fire.
|
||||||
|
ref.read(pendingJumpEventProvider.notifier).state = null;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _attemptJump(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _attemptJump(int attempt) {
|
||||||
|
if (!mounted || _jumpEventId == null) return;
|
||||||
|
final ctx = _jumpKey.currentContext;
|
||||||
|
if (ctx != null) {
|
||||||
|
Scrollable.ensureVisible(
|
||||||
|
ctx,
|
||||||
|
alignment: 0.4,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
_jumpInProgress = false;
|
||||||
|
// Hold the highlight briefly, then fade it out smoothly.
|
||||||
|
_highlightTimer?.cancel();
|
||||||
|
_highlightTimer = Timer(const Duration(milliseconds: 1300), () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _highlightOn = false); // triggers the AnimatedOpacity fade
|
||||||
|
_highlightTimer = Timer(const Duration(milliseconds: 950), () {
|
||||||
|
if (mounted) setState(() { _highlightEventId = null; _jumpEventId = null; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt >= 40) {
|
||||||
|
// Couldn't reach the event — give up gracefully (room is at least open).
|
||||||
|
setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Not built yet — load older history toward the target every few tries.
|
||||||
|
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||||
|
if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) {
|
||||||
|
timeline.requestHistory(historyCount: 100).catchError((_) {});
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlobalKey for the "Neue Nachrichten" divider — used by ensureVisible to
|
||||||
|
// scroll precisely to the divider on chat open.
|
||||||
|
final _unreadDividerKey = GlobalKey();
|
||||||
|
|
||||||
|
bool get _inSelectionMode => _selectedIds.isNotEmpty;
|
||||||
|
|
||||||
|
void _toggleSelect(String eventId) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedIds.contains(eventId)) {
|
||||||
|
_selectedIds.remove(eventId);
|
||||||
|
} else {
|
||||||
|
_selectedIds.add(eventId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSelection() => setState(() => _selectedIds.clear());
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollCtrl.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(ChatView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.roomId != widget.roomId) {
|
||||||
|
_profileExpanded = false;
|
||||||
|
_sessionFullyReadId = null;
|
||||||
|
_sessionHasUnread = false;
|
||||||
|
_sessionCaptured = false;
|
||||||
|
_didScrollToUnread = false;
|
||||||
|
_loadingFuture = false;
|
||||||
|
_historyExhausted = false;
|
||||||
|
_unreadFading = false;
|
||||||
|
_unreadFadeTimer?.cancel();
|
||||||
|
_jumpEventId = null;
|
||||||
|
_highlightEventId = null;
|
||||||
|
_highlightOn = false;
|
||||||
|
_jumpInProgress = false;
|
||||||
|
_highlightTimer?.cancel();
|
||||||
|
// The GlobalKey stays alive — it'll simply have no context until the
|
||||||
|
// new room's divider (if any) is built.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_unreadFadeTimer?.cancel();
|
||||||
|
_highlightTimer?.cancel();
|
||||||
|
_scrollCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _captureAndMarkRead(Timeline timeline) {
|
||||||
|
if (!_sessionCaptured) {
|
||||||
|
final room = timeline.room;
|
||||||
|
final fullyRead = room.fullyRead; // "" if never set
|
||||||
|
final newFullyReadId = fullyRead.isNotEmpty ? fullyRead : null;
|
||||||
|
|
||||||
|
// Show the divider only if there's a genuinely unread VISIBLE message
|
||||||
|
// FROM SOMEONE ELSE after the read marker. Own messages and hidden events
|
||||||
|
// (reactions, receipts, edits) don't count — otherwise the divider ends
|
||||||
|
// up below all messages (e.g. right after one's own latest message).
|
||||||
|
final me = room.client.userID;
|
||||||
|
final events = timeline.events; // newest-first
|
||||||
|
final frIndex =
|
||||||
|
fullyRead.isEmpty ? -1 : events.indexWhere((e) => e.eventId == fullyRead);
|
||||||
|
// Indices [0, upTo) are newer than the read marker.
|
||||||
|
final upTo = frIndex >= 0 ? frIndex : events.length;
|
||||||
|
var newHasUnread = false;
|
||||||
|
for (var i = 0; i < upTo; i++) {
|
||||||
|
final e = events[i];
|
||||||
|
if (_MessageList._isVisible(e) && e.senderId != me) {
|
||||||
|
newHasUnread = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_sessionCaptured = true;
|
||||||
|
|
||||||
|
// setState so _MessageList rebuilds with the captured unread marker.
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_sessionFullyReadId = newFullyReadId;
|
||||||
|
_sessionHasUnread = newHasUnread;
|
||||||
|
});
|
||||||
|
if (newHasUnread) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_markRead(timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToUnread(Timeline timeline) {
|
||||||
|
if (_didScrollToUnread) return;
|
||||||
|
_attemptScrollToUnread(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "Neue Nachrichten" divider may not be laid out yet on first open (lazy
|
||||||
|
// reverse ListView only builds visible + cacheExtent items). Retry until its
|
||||||
|
// context exists, nudging the scroll upward periodically so the list builds
|
||||||
|
// toward the divider when there are many unread messages.
|
||||||
|
void _attemptScrollToUnread(int attempt) {
|
||||||
|
if (_didScrollToUnread || !mounted) return;
|
||||||
|
final ctx = _unreadDividerKey.currentContext;
|
||||||
|
if (ctx != null) {
|
||||||
|
_didScrollToUnread = true;
|
||||||
|
// alignment 0.85: in a reverse list this lands the divider near the top
|
||||||
|
// of the viewport with a little read context above it and the unread
|
||||||
|
// messages filling the screen below.
|
||||||
|
Scrollable.ensureVisible(
|
||||||
|
ctx,
|
||||||
|
alignment: 0.85,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt >= 24) {
|
||||||
|
_didScrollToUnread = true; // give up — stay at the newest messages
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) {
|
||||||
|
final pos = _scrollCtrl.position;
|
||||||
|
final target = (pos.pixels + 1500).clamp(0.0, pos.maxScrollExtent);
|
||||||
|
if (target > pos.pixels) _scrollCtrl.jumpTo(target);
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 70),
|
||||||
|
() => _attemptScrollToUnread(attempt + 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markRead([Timeline? tl]) {
|
||||||
|
// Cancel any pending notification for this room (user is now reading it).
|
||||||
|
cancelNotificationForRoom(widget.roomId);
|
||||||
|
|
||||||
|
final sendReceipts = ref.read(privacyReadReceiptsProvider);
|
||||||
|
if (!sendReceipts) return;
|
||||||
|
final room = ref.read(roomProvider(widget.roomId));
|
||||||
|
if (room == null) return;
|
||||||
|
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||||
|
final latest = timeline?.events.firstOrNull;
|
||||||
|
if (latest != null && latest.status.isSynced) {
|
||||||
|
room.setReadMarker(latest.eventId, mRead: latest.eventId)
|
||||||
|
.catchError((_) {});
|
||||||
|
} else {
|
||||||
|
room.markUnread(false).catchError((_) {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onScroll() async {
|
||||||
|
if (!_scrollCtrl.hasClients) return;
|
||||||
|
final pixels = _scrollCtrl.position.pixels;
|
||||||
|
final maxExtent = _scrollCtrl.position.maxScrollExtent;
|
||||||
|
|
||||||
|
if (pixels < 60) {
|
||||||
|
_markRead(null);
|
||||||
|
// Reached the newest messages → the "Neue Nachrichten" divider is no
|
||||||
|
// longer useful. Fade it out, then remove it.
|
||||||
|
if (_sessionHasUnread && !_unreadFading) {
|
||||||
|
setState(() => _unreadFading = true);
|
||||||
|
_unreadFadeTimer?.cancel();
|
||||||
|
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
|
||||||
|
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final shouldShow = pixels > 400;
|
||||||
|
if (shouldShow != _showScrollDown) {
|
||||||
|
setState(() => _showScrollDown = shouldShow);
|
||||||
|
}
|
||||||
|
|
||||||
|
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||||
|
if (timeline == null) return;
|
||||||
|
|
||||||
|
// Backward pagination — older events near the top. We keep requesting until
|
||||||
|
// a request genuinely returns nothing new (real start of room), tracked via
|
||||||
|
// _historyExhausted, so we neither stop early nor hammer the server.
|
||||||
|
if (!_loadingHistory &&
|
||||||
|
!_historyExhausted &&
|
||||||
|
pixels >= maxExtent - 600) {
|
||||||
|
_loadingHistory = true;
|
||||||
|
final oldestBefore = timeline.events.isNotEmpty ? timeline.events.last.eventId : null;
|
||||||
|
final lenBefore = timeline.events.length;
|
||||||
|
try {
|
||||||
|
await timeline.requestHistory(historyCount: 60);
|
||||||
|
} catch (_) {}
|
||||||
|
final oldestAfter = timeline.events.isNotEmpty ? timeline.events.last.eventId : null;
|
||||||
|
// Nothing older arrived → we've reached the beginning of the room.
|
||||||
|
if (timeline.events.length == lenBefore && oldestBefore == oldestAfter) {
|
||||||
|
_historyExhausted = true;
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
_loadingHistory = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward pagination — only relevant after jumping to a historical anchor
|
||||||
|
// (e.g. from search). At the live edge canRequestFuture is false, so this
|
||||||
|
// won't fire during normal reading and can't pull the view back down.
|
||||||
|
if (!_loadingFuture &&
|
||||||
|
timeline.canRequestFuture &&
|
||||||
|
pixels < 200) {
|
||||||
|
_loadingFuture = true;
|
||||||
|
try {
|
||||||
|
await timeline.requestFuture(historyCount: 60);
|
||||||
|
} catch (_) {}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 250));
|
||||||
|
_loadingFuture = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final room = ref.watch(roomProvider(widget.roomId));
|
||||||
|
final timelineAsync = ref.watch(timelineProvider(widget.roomId));
|
||||||
|
final membersOpen = ref.watch(membersPanelOpenProvider);
|
||||||
|
|
||||||
|
// A search result requested a jump to a specific message. Using watch (not
|
||||||
|
// listen) so it also fires when this ChatView is freshly mounted for a
|
||||||
|
// DIFFERENT room — listen would miss a value set before registration.
|
||||||
|
final pendingJump = ref.watch(pendingJumpEventProvider);
|
||||||
|
if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted &&
|
||||||
|
!_jumpInProgress &&
|
||||||
|
ref.read(pendingJumpEventProvider) == pendingJump) {
|
||||||
|
_startJump(pendingJump);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
|
||||||
|
final body = Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
color: pt.bg1,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (_inSelectionMode)
|
||||||
|
_SelectionHeader(
|
||||||
|
pt: pt,
|
||||||
|
count: _selectedIds.length,
|
||||||
|
selectedIds: Set.from(_selectedIds),
|
||||||
|
room: room,
|
||||||
|
timeline: ref.read(timelineProvider(widget.roomId)).valueOrNull,
|
||||||
|
currentUserId: room?.client.userID ?? '',
|
||||||
|
onClear: _clearSelection,
|
||||||
|
onReply: () {
|
||||||
|
final tl = ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||||
|
if (tl == null) return;
|
||||||
|
try {
|
||||||
|
final event = tl.events.firstWhere((e) => e.eventId == _selectedIds.first);
|
||||||
|
setState(() { _replyTo = event; _selectedIds.clear(); });
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else if (room != null && room.isDirectChat)
|
||||||
|
// DM: aufklappbarer Banner-Header — Avatar und Name
|
||||||
|
// skalieren mit, Banner und Bio blenden ein.
|
||||||
|
// ValueKey: pro Raum ein frischer State, damit beim
|
||||||
|
// schnellen Kanalwechsel keinerlei Daten (Avatar,
|
||||||
|
// Banner, laufende Futures) vom Vorgänger übrigbleiben.
|
||||||
|
_DmExpandingHeader(
|
||||||
|
key: ValueKey('dmheader:${room.id}'),
|
||||||
|
room: room,
|
||||||
|
roomId: widget.roomId,
|
||||||
|
pt: pt,
|
||||||
|
expanded: _profileExpanded,
|
||||||
|
onToggle: () => setState(
|
||||||
|
() => _profileExpanded = !_profileExpanded),
|
||||||
|
membersOpen: membersOpen,
|
||||||
|
pinnedOpen: _pinnedOpen,
|
||||||
|
onToggleMembers: () => ref
|
||||||
|
.read(membersPanelOpenProvider.notifier)
|
||||||
|
.update((s) => !s),
|
||||||
|
onOpenSearch: () =>
|
||||||
|
ref.read(activeModalProvider.notifier).state =
|
||||||
|
ModalKind.search,
|
||||||
|
onTogglePinned: () =>
|
||||||
|
setState(() => _pinnedOpen = !_pinnedOpen),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_ChatHeader(
|
||||||
|
room: room,
|
||||||
|
roomId: widget.roomId,
|
||||||
|
pt: pt,
|
||||||
|
membersOpen: membersOpen,
|
||||||
|
pinnedOpen: _pinnedOpen,
|
||||||
|
onToggleMembers: () => ref
|
||||||
|
.read(membersPanelOpenProvider.notifier)
|
||||||
|
.update((s) => !s),
|
||||||
|
onOpenSearch: () =>
|
||||||
|
ref.read(activeModalProvider.notifier).state =
|
||||||
|
ModalKind.search,
|
||||||
|
onTogglePinned: () =>
|
||||||
|
setState(() => _pinnedOpen = !_pinnedOpen),
|
||||||
|
),
|
||||||
|
const _ConnectionBanner(),
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
timelineAsync.when(
|
||||||
|
loading: () => Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: pt.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
error: (e, _) => Center(
|
||||||
|
child: Text(
|
||||||
|
'Could not load messages',
|
||||||
|
style: TextStyle(color: pt.fgDim, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data: (timeline) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => _captureAndMarkRead(timeline));
|
||||||
|
return _MessageList(
|
||||||
|
timeline: timeline,
|
||||||
|
scrollCtrl: _scrollCtrl,
|
||||||
|
currentUserId: room?.client.userID ?? '',
|
||||||
|
pt: pt,
|
||||||
|
onReply: (event) => setState(() => _replyTo = event),
|
||||||
|
selectedIds: _selectedIds,
|
||||||
|
onToggleSelect: _toggleSelect,
|
||||||
|
sessionFullyReadId: _sessionFullyReadId,
|
||||||
|
sessionHasUnread: _sessionHasUnread,
|
||||||
|
unreadFading: _unreadFading,
|
||||||
|
unreadDividerKey: _sessionHasUnread ? _unreadDividerKey : null,
|
||||||
|
jumpEventId: _jumpEventId,
|
||||||
|
jumpKey: _jumpKey,
|
||||||
|
highlightEventId: _highlightEventId,
|
||||||
|
highlightOn: _highlightOn,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_showScrollDown)
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
bottom: 12,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _showScrollDown ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _scrollCtrl.animateTo(
|
||||||
|
0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg0,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: pt.border),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withAlpha(80),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: pt.fgMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (room != null) _TypingIndicator(room: room, pt: pt),
|
||||||
|
if (room != null)
|
||||||
|
ChatComposer(
|
||||||
|
room: room,
|
||||||
|
replyTo: _replyTo,
|
||||||
|
onClearReply: () => setState(() => _replyTo = null),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_pinnedOpen)
|
||||||
|
PinnedMessagesPanel(
|
||||||
|
roomId: widget.roomId,
|
||||||
|
onClose: () => setState(() => _pinnedOpen = false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Drag-and-drop overlay — bleibt gemountet und blendet animiert
|
||||||
|
// ein/aus, damit der Übergang flüssig statt abrupt ist.
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _draggingOver ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 160),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
child: _DragOverlay(pt: pt, active: _draggingOver),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isDesktop) return body;
|
||||||
|
|
||||||
|
return DropTarget(
|
||||||
|
onDragEntered: (_) => setState(() => _draggingOver = true),
|
||||||
|
onDragExited: (_) => setState(() => _draggingOver = false),
|
||||||
|
onDragDone: (details) async {
|
||||||
|
setState(() => _draggingOver = false);
|
||||||
|
if (room == null) return;
|
||||||
|
final files = details.files.map((xf) => File(xf.path)).toList();
|
||||||
|
if (files.isEmpty) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
await AttachmentDialog.show(context, files, room, replyTo: _replyTo);
|
||||||
|
if (mounted) setState(() => _replyTo = null);
|
||||||
|
},
|
||||||
|
child: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user