fix: Haken live, Einladung im Chat annehmen, Trenner verschwindet zuverlässig
Bernds Test mit der PC-Testinstanz: - ✓✓ erschien erst nach erneutem Öffnen: eine Lesebestätigung ohne neue Nachricht zeichnete nichts neu (Raum-Update kommt im SDK vor der Bestätigung). Jetzt nach jedem Sync Fingerabdruck der Lesepositionen prüfen und bei Änderung neu zeichnen. - Einladung: Chat zeigte leeren Verlauf, Annehmen ging nur in der Liste, und danach blieb der Chat leer bis zum App-Neustart. Jetzt Annehmen/Ablehnen direkt im Chat; Annehmen wartet auf den Beitritts-Sync, der Verlauf baut sich bei invite→join selbst neu auf. - Trenner blieb stehen, wenn nach dem Öffnen/Zurückkommen nicht gescrollt wurde. Jetzt: neueste Nachrichten sichtbar (≤ halbe Bildschirmhöhe hochgescrollt) → nach 6 s weich ausblenden, auch ohne Scrollen. - Live-Test mit beiden Testkonten (Gegenprobe: ohne Fix rot) in scripts/test.ps1 -Ui; Testlauf-Sperre erkennt nur noch eigene Testläufe. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a6461fe23c
commit
c817de924a
@@ -0,0 +1,243 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
|
||||||
|
/// Live mit beiden Testkonten (Bernds Fehlerbericht 2026-10-07):
|
||||||
|
/// 1. ✓✓ erscheint LIVE, sobald der Partner liest – ohne den Chat neu zu
|
||||||
|
/// öffnen (vorher zeichnete eine reine Lesebestätigung nichts neu).
|
||||||
|
/// 2. Einladung lässt sich direkt im Chat annehmen, danach ist der Verlauf
|
||||||
|
/// sofort da (vorher leerer Chat bis zum App-Neustart).
|
||||||
|
///
|
||||||
|
/// Die App läuft mit dem Testprofil (PYRAMID_PROFILE_DIR, pyramidtest1).
|
||||||
|
/// pyramidtest2 wird per Client-Server-API mit einem kurzlebigen Gerät
|
||||||
|
/// gesteuert, das am Ende abgemeldet wird – andere Sitzungen (z. B. Bernds
|
||||||
|
/// Testinstanz mit profile2) bleiben unberührt.
|
||||||
|
///
|
||||||
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
/// flutter test integration_test/live_receipts_invite_test.dart -d windows
|
||||||
|
const _hs = 'https://steggi-matrix.work';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
final accounts = _loadAccounts();
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Lesebestätigung live + Einladung im Chat annehmen',
|
||||||
|
(tester) async {
|
||||||
|
tester.view
|
||||||
|
..physicalSize = const Size(1080, 2340)
|
||||||
|
..devicePixelRatio = 2.625;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
final partner = await tester.runAsync(
|
||||||
|
() => _RestUser.login(accounts!['user2']!, accounts['pass2']!),
|
||||||
|
);
|
||||||
|
addTearDown(() => tester.runAsync(() => partner!.logout()));
|
||||||
|
|
||||||
|
app.main();
|
||||||
|
await _pumpUntil(tester, find.byType(MaterialApp));
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(MaterialApp).first),
|
||||||
|
);
|
||||||
|
final client = (await tester.runAsync(
|
||||||
|
() => container.read(matrixClientProvider.future),
|
||||||
|
))!;
|
||||||
|
expect(client.userID, '@pyramidtest1:steggi-matrix.work');
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 4));
|
||||||
|
|
||||||
|
// ── 1. Lesebestätigung live ──
|
||||||
|
final dm = client.rooms.firstWhere(
|
||||||
|
(r) =>
|
||||||
|
r.membership == Membership.join &&
|
||||||
|
r.directChatMatrixID == '@pyramidtest2:steggi-matrix.work',
|
||||||
|
);
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = dm.id;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 4));
|
||||||
|
|
||||||
|
final text = 'Lesetest ${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final eventId = await tester.runAsync(() => dm.sendTextEvent(text));
|
||||||
|
await _pumpUntil(tester, find.textContaining(text));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
expect(
|
||||||
|
find.byIcon(Icons.done_rounded),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: 'eigene neueste Nachricht sollte noch ✓ (ungelesen) zeigen',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.runAsync(() => partner!.readReceipt(dm.id, eventId!));
|
||||||
|
// Ohne den Chat neu zu öffnen: ✓ wird zu ✓✓ direkt unter der Nachricht.
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.byIcon(Icons.done_rounded),
|
||||||
|
gone: true,
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
final msgY = tester.getCenter(find.textContaining(text).first).dy;
|
||||||
|
final readIcons = find.byIcon(Icons.done_all_rounded).evaluate();
|
||||||
|
expect(
|
||||||
|
readIcons.any((e) {
|
||||||
|
final box = e.renderObject as RenderBox;
|
||||||
|
final y = box.localToGlobal(box.size.center(Offset.zero)).dy;
|
||||||
|
return y > msgY && y - msgY < 120;
|
||||||
|
}),
|
||||||
|
isTrue,
|
||||||
|
reason: '✓✓ erscheint nicht live unter der gelesenen Nachricht',
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 2. Einladung im Chat annehmen ──
|
||||||
|
final welcome = 'Willkommen im Einladungstest ${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final inviteRoomId = await tester.runAsync(() async {
|
||||||
|
final id = await partner!.createRoom('Einladungstest');
|
||||||
|
await partner.sendText(id, welcome);
|
||||||
|
await partner.invite(id, client.userID!);
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
addTearDown(() => tester.runAsync(() => partner!.leave(inviteRoomId!)));
|
||||||
|
|
||||||
|
// Einladung kommt per Sync an → Chat öffnen (wie aus der Benachrichtigung).
|
||||||
|
final end = DateTime.now().add(const Duration(seconds: 30));
|
||||||
|
while (client.getRoomById(inviteRoomId!)?.membership != Membership.invite &&
|
||||||
|
DateTime.now().isBefore(end)) {
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
|
||||||
|
await _pumpUntil(tester, find.text('Annehmen'));
|
||||||
|
await tester.tap(find.text('Annehmen'));
|
||||||
|
// Ohne App-Neustart: Verlauf mit der Begrüßung erscheint.
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.textContaining(welcome),
|
||||||
|
timeout: const Duration(seconds: 40),
|
||||||
|
);
|
||||||
|
expect(find.text('Annehmen'), findsNothing);
|
||||||
|
|
||||||
|
// Aufräumen: Testraum verlassen + vergessen.
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
final r = client.getRoomById(inviteRoomId);
|
||||||
|
await r?.leave();
|
||||||
|
await r?.forget().catchError((_) {});
|
||||||
|
});
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
},
|
||||||
|
skip: profile == null || accounts == null,
|
||||||
|
timeout: const Timeout(Duration(minutes: 4)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String>? _loadAccounts() {
|
||||||
|
final home = Platform.environment['USERPROFILE'] ?? '';
|
||||||
|
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
|
||||||
|
if (!file.existsSync()) return null;
|
||||||
|
final map = <String, String>{};
|
||||||
|
for (final line in file.readAsLinesSync()) {
|
||||||
|
final t = line.trim();
|
||||||
|
if (t.isEmpty || t.startsWith('#')) continue;
|
||||||
|
final i = t.indexOf('=');
|
||||||
|
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimaler Matrix-Zugang per Client-Server-API (kurzlebiges Gerät).
|
||||||
|
class _RestUser {
|
||||||
|
_RestUser(this._token);
|
||||||
|
final String _token;
|
||||||
|
int _txn = 0;
|
||||||
|
|
||||||
|
static Future<_RestUser> login(String user, String pass) async {
|
||||||
|
final res = await http.post(
|
||||||
|
Uri.parse('$_hs/_matrix/client/v3/login'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'type': 'm.login.password',
|
||||||
|
'identifier': {'type': 'm.id.user', 'user': user},
|
||||||
|
'password': pass,
|
||||||
|
'initial_device_display_name': 'Pyramid-Livetest (kurzlebig)',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (res.statusCode != 200) throw StateError('Login ${res.statusCode}');
|
||||||
|
return _RestUser(jsonDecode(res.body)['access_token'] as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _call(String method, String path,
|
||||||
|
[Object? body]) async {
|
||||||
|
final req = http.Request(method, Uri.parse('$_hs/_matrix/client/v3$path'))
|
||||||
|
..headers['Authorization'] = 'Bearer $_token'
|
||||||
|
..headers['Content-Type'] = 'application/json'
|
||||||
|
..body = jsonEncode(body ?? {});
|
||||||
|
final res = await http.Response.fromStream(await req.send());
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw StateError('$method $path → ${res.statusCode}');
|
||||||
|
}
|
||||||
|
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _enc(String s) => Uri.encodeComponent(s);
|
||||||
|
|
||||||
|
Future<void> readReceipt(String roomId, String eventId) => _call(
|
||||||
|
'POST',
|
||||||
|
'/rooms/${_enc(roomId)}/read_markers',
|
||||||
|
{'m.fully_read': eventId, 'm.read': eventId},
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<String> createRoom(String name) async =>
|
||||||
|
(await _call('POST', '/createRoom', {
|
||||||
|
'name': name,
|
||||||
|
'preset': 'private_chat',
|
||||||
|
}))['room_id'] as String;
|
||||||
|
|
||||||
|
Future<void> sendText(String roomId, String text) => _call(
|
||||||
|
'PUT',
|
||||||
|
'/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}',
|
||||||
|
{'msgtype': 'm.text', 'body': text},
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> invite(String roomId, String userId) =>
|
||||||
|
_call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId});
|
||||||
|
|
||||||
|
Future<void> leave(String roomId) async {
|
||||||
|
try {
|
||||||
|
await _call('POST', '/rooms/${_enc(roomId)}/leave');
|
||||||
|
await _call('POST', '/rooms/${_enc(roomId)}/forget');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout() async {
|
||||||
|
try {
|
||||||
|
await _call('POST', '/logout');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpFor(WidgetTester tester, Duration d) async {
|
||||||
|
final end = DateTime.now().add(d);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
bool gone = false,
|
||||||
|
Duration timeout = const Duration(seconds: 60),
|
||||||
|
}) async {
|
||||||
|
final end = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
final found = finder.evaluate().isNotEmpty;
|
||||||
|
if (found != gone) return;
|
||||||
|
}
|
||||||
|
fail('Zeitüberschreitung: $finder ${gone ? "verschwindet nicht" : "nicht gefunden"}');
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ import 'package:pyramid/core/matrix_client.dart';
|
|||||||
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
||||||
Timeline? _timeline;
|
Timeline? _timeline;
|
||||||
StreamSubscription<String>? _roomUpdateSub;
|
StreamSubscription<String>? _roomUpdateSub;
|
||||||
|
StreamSubscription<SyncUpdate>? _syncSub;
|
||||||
Timer? _redecryptTimer;
|
Timer? _redecryptTimer;
|
||||||
|
String _receiptSig = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Timeline> build(String arg) async {
|
Future<Timeline> build(String arg) async {
|
||||||
@@ -21,6 +23,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
|||||||
|
|
||||||
_timeline?.cancelSubscriptions();
|
_timeline?.cancelSubscriptions();
|
||||||
_roomUpdateSub?.cancel();
|
_roomUpdateSub?.cancel();
|
||||||
|
_syncSub?.cancel();
|
||||||
_redecryptTimer?.cancel();
|
_redecryptTimer?.cancel();
|
||||||
|
|
||||||
final diag = ref.read(e2eeDiagnosticsProvider.notifier);
|
final diag = ref.read(e2eeDiagnosticsProvider.notifier);
|
||||||
@@ -49,6 +52,28 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nach jedem Sync (erst dann sind Lesebestätigungen verarbeitet – das
|
||||||
|
// Raum-Update kommt im SDK VORHER):
|
||||||
|
// • Einladung angenommen (invite → join): Verlauf neu aufbauen; der beim
|
||||||
|
// Einladen gebaute blieb sonst leer, bis man die App neu startete.
|
||||||
|
// • Lesebestätigung eines anderen hat sich geändert: neu zeichnen, damit
|
||||||
|
// ✓✓ live erscheint. Neue Nachrichten allein lösen _onUpdate schon aus,
|
||||||
|
// eine Bestätigung ohne neue Nachricht bisher nicht.
|
||||||
|
final membershipAtBuild = room.membership;
|
||||||
|
_receiptSig = _receiptSignature(room);
|
||||||
|
_syncSub = client.onSync.stream.listen((_) {
|
||||||
|
if (membershipAtBuild != Membership.join &&
|
||||||
|
room.membership == Membership.join) {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final sig = _receiptSignature(room);
|
||||||
|
if (sig != _receiptSig) {
|
||||||
|
_receiptSig = sig;
|
||||||
|
_onUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Ensure device keys are downloaded for all room members so outbound
|
// Ensure device keys are downloaded for all room members so outbound
|
||||||
// Megolm session key sharing works on the first send.
|
// Megolm session key sharing works on the first send.
|
||||||
if (room.encrypted && client.encryptionEnabled) {
|
if (room.encrypted && client.encryptionEnabled) {
|
||||||
@@ -57,6 +82,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
|||||||
|
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
_roomUpdateSub?.cancel();
|
_roomUpdateSub?.cancel();
|
||||||
|
_syncSub?.cancel();
|
||||||
_redecryptTimer?.cancel();
|
_redecryptTimer?.cancel();
|
||||||
_timeline?.cancelSubscriptions();
|
_timeline?.cancelSubscriptions();
|
||||||
_timeline = null;
|
_timeline = null;
|
||||||
@@ -70,6 +96,36 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kurzer Fingerabdruck der Lesepositionen der anderen Mitglieder.
|
||||||
|
String _receiptSignature(Room room) {
|
||||||
|
final b = StringBuffer();
|
||||||
|
for (final users in [
|
||||||
|
room.receiptState.global.otherUsers,
|
||||||
|
room.receiptState.mainThread?.otherUsers ?? const {},
|
||||||
|
]) {
|
||||||
|
users.forEach((userId, data) => b
|
||||||
|
..write(userId)
|
||||||
|
..write('=')
|
||||||
|
..write(data.eventId)
|
||||||
|
..write(';'));
|
||||||
|
}
|
||||||
|
return b.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einladung annehmen und warten, bis der Raum als beigetreten
|
||||||
|
/// synchronisiert ist – erst dann hat der Verlauf Inhalt.
|
||||||
|
Future<void> acceptInvite(WidgetRef ref, Room room) async {
|
||||||
|
final client = room.client;
|
||||||
|
// Vor dem Beitreten anfangen zu warten, sonst kann der Sync durchrutschen.
|
||||||
|
final synced = client
|
||||||
|
.waitForRoomInSync(room.id, join: true)
|
||||||
|
.timeout(const Duration(seconds: 20))
|
||||||
|
.then<void>((_) {}, onError: (_) {});
|
||||||
|
await room.join();
|
||||||
|
await synced;
|
||||||
|
ref.invalidate(timelineProvider(room.id));
|
||||||
|
}
|
||||||
|
|
||||||
final timelineProvider =
|
final timelineProvider =
|
||||||
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
|
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
|
||||||
TimelineNotifier.new,
|
TimelineNotifier.new,
|
||||||
|
|||||||
@@ -151,3 +151,142 @@ class _FloatingComposerArea extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Einladung in diesen Chat (z. B. aus einer Benachrichtigung geöffnet):
|
||||||
|
/// Annehmen/Ablehnen direkt hier statt eines leeren Verlaufs – vorher ging
|
||||||
|
/// das nur in der Chatliste (Bernd 2026-10-07).
|
||||||
|
class _InvitePanel extends ConsumerStatefulWidget {
|
||||||
|
final Room room;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _InvitePanel({required this.room, required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_InvitePanel> createState() => _InvitePanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvitePanelState extends ConsumerState<_InvitePanel> {
|
||||||
|
bool _busy = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
String get _inviter {
|
||||||
|
final me = widget.room.client.userID;
|
||||||
|
final sender = me == null
|
||||||
|
? null
|
||||||
|
: widget.room.getState(EventTypes.RoomMember, me)?.senderId;
|
||||||
|
if (sender == null) return 'Jemand';
|
||||||
|
return widget.room.unsafeGetUserFromMemoryOrFallback(sender).calcDisplayname();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _accept() async {
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await acceptInvite(ref, widget.room);
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
_error = 'Annehmen hat nicht geklappt – bitte noch einmal versuchen.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _decline() async {
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await widget.room.leave();
|
||||||
|
if (mounted) ref.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
_error = 'Ablehnen hat nicht geklappt – bitte noch einmal versuchen.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
final isDm = widget.room.isDirectChat;
|
||||||
|
return Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(28),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isDm ? Icons.person_add_alt_1_rounded : Icons.group_add_rounded,
|
||||||
|
size: 48,
|
||||||
|
color: pt.accent,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
isDm
|
||||||
|
? '$_inviter möchte mit dir chatten'
|
||||||
|
: '$_inviter lädt dich in „${widget.room.getLocalizedDisplayname()}“ ein',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Nach dem Annehmen siehst du die Nachrichten und kannst antworten.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 13.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (_busy)
|
||||||
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.5, color: pt.accent),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
children: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _decline,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: pt.fgMuted,
|
||||||
|
side: BorderSide(color: pt.border),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14),
|
||||||
|
),
|
||||||
|
child: const Text('Ablehnen'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _accept,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: pt.accent,
|
||||||
|
foregroundColor: pt.accentFg,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 14),
|
||||||
|
),
|
||||||
|
child: const Text('Annehmen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(_error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: pt.danger, fontSize: 13)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
cancelNotificationForRoom(widget.roomId);
|
cancelNotificationForRoom(widget.roomId);
|
||||||
|
|
||||||
final room = ref.read(roomProvider(widget.roomId));
|
final room = ref.read(roomProvider(widget.roomId));
|
||||||
if (room == null) return;
|
if (room == null || room.membership != Membership.join) return;
|
||||||
if (room.markedUnread) room.markUnread(false).catchError((_) {});
|
if (room.markedUnread) room.markUnread(false).catchError((_) {});
|
||||||
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||||
final latest = timeline?.events
|
final latest = timeline?.events
|
||||||
@@ -326,14 +326,21 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
_sessionHasUnread = true;
|
_sessionHasUnread = true;
|
||||||
_unreadFading = false;
|
_unreadFading = false;
|
||||||
});
|
});
|
||||||
_scheduleUnreadFadeIfAtNewest();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (_sessionHasUnread) _scheduleUnreadFadeIfAtNewest();
|
||||||
_markRead(timeline);
|
_markRead(timeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _atNewest =>
|
// Neueste Nachrichten sind zu sehen: höchstens eine halbe Bildschirmhöhe
|
||||||
!_scrollCtrl.hasClients || _scrollCtrl.position.pixels < 60;
|
// hochgescrollt. (Nach dem Sprung zum Trenner steht die Liste meist ein
|
||||||
|
// Stück oberhalb des Endes – mit der strengen 60-px-Grenze blieb der
|
||||||
|
// Trenner dann für immer stehen.)
|
||||||
|
bool get _atNewest {
|
||||||
|
if (!_scrollCtrl.hasClients) return true;
|
||||||
|
final pos = _scrollCtrl.position;
|
||||||
|
return pos.pixels < 60 || pos.pixels < pos.viewportDimension * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
/// „Neue Nachrichten“-Trenner nach [_unreadDwell] weich ausblenden – nur
|
/// „Neue Nachrichten“-Trenner nach [_unreadDwell] weich ausblenden – nur
|
||||||
/// wenn man dann noch bei den neuesten Nachrichten ist. Wer zwischendurch
|
/// wenn man dann noch bei den neuesten Nachrichten ist. Wer zwischendurch
|
||||||
@@ -342,7 +349,10 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
if (!_sessionHasUnread || _unreadFading) return;
|
if (!_sessionHasUnread || _unreadFading) return;
|
||||||
if (_unreadDwellTimer?.isActive ?? false) return;
|
if (_unreadDwellTimer?.isActive ?? false) return;
|
||||||
_unreadDwellTimer = Timer(_unreadDwell, () {
|
_unreadDwellTimer = Timer(_unreadDwell, () {
|
||||||
if (!mounted || !_sessionHasUnread || !_atNewest) return;
|
if (!mounted || !_sessionHasUnread) return;
|
||||||
|
// App im Hintergrund oder weggescrollt: stehen lassen, beim nächsten
|
||||||
|
// Scrollen/Zurückkommen beginnt die Wartezeit neu.
|
||||||
|
if (!_atNewest || !ref.read(windowFocusedProvider)) return;
|
||||||
setState(() => _unreadFading = true);
|
setState(() => _unreadFading = true);
|
||||||
_unreadFadeTimer?.cancel();
|
_unreadFadeTimer?.cancel();
|
||||||
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
|
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
|
||||||
@@ -365,12 +375,10 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
final pixels = _scrollCtrl.position.pixels;
|
final pixels = _scrollCtrl.position.pixels;
|
||||||
final maxExtent = _scrollCtrl.position.maxScrollExtent;
|
final maxExtent = _scrollCtrl.position.maxScrollExtent;
|
||||||
|
|
||||||
if (pixels < 60) {
|
if (pixels < 60) _markRead(null);
|
||||||
_markRead(null);
|
// Bei den neuesten Nachrichten angekommen → Trenner nach einer
|
||||||
// Bei den neuesten Nachrichten angekommen → Trenner nach einer
|
// Verweildauer ausblenden.
|
||||||
// Verweildauer ausblenden.
|
if (_atNewest) _scheduleUnreadFade();
|
||||||
_scheduleUnreadFade();
|
|
||||||
}
|
|
||||||
final shouldShow = pixels > 400;
|
final shouldShow = pixels > 400;
|
||||||
if (shouldShow != _showScrollDown) {
|
if (shouldShow != _showScrollDown) {
|
||||||
setState(() => _showScrollDown = shouldShow);
|
setState(() => _showScrollDown = shouldShow);
|
||||||
@@ -439,6 +447,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
|
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
|
||||||
|
// Noch nicht beigetreten (Einladung): statt eines leeren Verlaufs gleich
|
||||||
|
// hier Annehmen/Ablehnen anbieten.
|
||||||
|
final isInvite = room?.membership == Membership.invite;
|
||||||
final body = Stack(
|
final body = Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
@@ -510,7 +521,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
),
|
),
|
||||||
const _ConnectionBanner(),
|
const _ConnectionBanner(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: isInvite
|
||||||
|
? _InvitePanel(room: room!, pt: pt)
|
||||||
|
: Stack(
|
||||||
children: [
|
children: [
|
||||||
timelineAsync.when(
|
timelineAsync.when(
|
||||||
loading: () => Center(
|
loading: () => Center(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class _InviteItemState extends ConsumerState<_InviteItem> {
|
|||||||
Future<void> _accept() async {
|
Future<void> _accept() async {
|
||||||
setState(() => _accepting = true);
|
setState(() => _accepting = true);
|
||||||
try {
|
try {
|
||||||
await widget.room.join();
|
await acceptInvite(ref, widget.room);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
|
ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
|
||||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:pyramid/core/matrix_client.dart';
|
|||||||
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||||
|
import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite;
|
||||||
import 'package:pyramid/features/call_ui/mini_call_widget.dart';
|
import 'package:pyramid/features/call_ui/mini_call_widget.dart';
|
||||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||||
|
|||||||
+14
-3
@@ -109,15 +109,26 @@ if ($Ui) {
|
|||||||
$uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1"
|
$uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1"
|
||||||
if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) {
|
if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) {
|
||||||
Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile"
|
Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile"
|
||||||
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue) {
|
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue |
|
||||||
# Nie zwei Matrix-Clients auf derselben DB – und Bernds Fenster nicht stören.
|
Where-Object { $_.Path -like "*\build\windows\*" }) {
|
||||||
Add-Skipped "UI-Durchlauf (Handy-Format)" "Pyramid läuft gerade – erst beenden"
|
# Nie zwei Matrix-Clients auf derselben DB: ein noch laufender Testlauf
|
||||||
|
# (Build-Ordner) nutzt profile1. Bernds echte Pyramid und seine
|
||||||
|
# Testinstanz (profile2, C:\Users\nordm\pyramid-testinstanz) stören nicht.
|
||||||
|
Add-Skipped "UI-Durchlauf (Handy-Format)" "Ein Testlauf läuft noch – erst beenden"
|
||||||
} else {
|
} else {
|
||||||
$env:PYRAMID_PROFILE_DIR = $uiProfile
|
$env:PYRAMID_PROFILE_DIR = $uiProfile
|
||||||
try {
|
try {
|
||||||
Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" {
|
Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" {
|
||||||
flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
||||||
}
|
}
|
||||||
|
$accounts = Join-Path $env:USERPROFILE ".pyramid-autopilot\test-accounts.txt"
|
||||||
|
if (Test-Path $accounts) {
|
||||||
|
Invoke-Step "Live: Lesebestätigung + Einladung (2 Testkonten)" {
|
||||||
|
flutter test integration_test/live_receipts_invite_test.dart -d windows
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Add-Skipped "Live: Lesebestätigung + Einladung" "test-accounts.txt fehlt"
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
|
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user