Bernds Test 2026-10-08 (Streamen ging weder am PC noch am Handy, Ton kam nicht an, Trenner blieb manchmal stehen) – mit neuem Live-Anruftest (zwei Testkonten im selben Prozess) nachgestellt und behoben: - Bildschirm teilen: Die Stromzuordnung rief ein SDK-getFeeds() auf, das es nicht gibt; der Fehler wurde geschluckt → Übertragungen wurden nie angezeigt und beim Sender nie als aktiv erkannt. Jetzt typisierte Zugriffe (remote/localScreenSharingStream, 'stopped' beachtet). - Verbindungsaufbau: matrix-SDK verwirft Kandidaten, die vor der fertig verarbeiteten Einladung bzw. während des Annehmens eintreffen; unter Windows wirft getRemoteDescription zudem statt null zu liefern. Pyramid puffert alle Kandidaten der Gegenseite und reicht sie direkt an die Verbindung nach. Test: vorher nie verbunden, jetzt ~0,5 s (angerufen) bzw. ~2,7 s (anrufend), Ton beide Richtungen, Teilen beide Richtungen. - SDK-Aufrufe in eigener Zone: harmlose Plugin-Fehler (Lautsprecher unter Windows) werden protokolliert statt durchzuschlagen; Ergebnisse/Fehler per Completer über die Zonengrenze (sonst ewiges Warten). - Sprech-Ring auch bei 1:1-Anrufen (Pegel aus WebRTC-Statistiken) – zeigt, ob das eigene Mikrofon Ton liefert und ob die Gegenseite ankommt; großes Bild springt nicht mehr bei jedem Sprechwechsel. - „Neue Nachrichten“ mitten in einer Gruppe (mehrere Nachrichten desselben Absenders) blieb bis zum erneuten Öffnen stehen: Inline-Trenner jetzt nur mit Ungelesenem, blendet aus und trägt den Sprungschlüssel. Live-Test. - „Anruf läuft“-Leiste nicht schon beim Klingeln. Co-Authored-By: Claude Opus 5.5 <[email protected]>
361 lines
11 KiB
Dart
361 lines
11 KiB
Dart
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Placeholder shown when no room is selected
|
||
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(
|
||
'Wähle einen Chat aus',
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Fläche der schwebenden Eingabeleiste: um das Eingabefeld herum völlig
|
||
/// klar – der Chat ist dort normal zu sehen –, fängt aber alle Berührungen
|
||
/// ab (ColoredBox ist auch transparent „opak“ für Treffer), damit dort nur
|
||
/// die Eingabe reagiert und nicht der Chat dahinter. Die Unschärfe sitzt nur
|
||
/// hinter dem Eingabefeld selbst (chat_composer.dart).
|
||
class _FloatingComposerArea extends StatelessWidget {
|
||
final PyramidTheme pt;
|
||
final Widget child;
|
||
const _FloatingComposerArea({required this.pt, required this.child});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ColoredBox(
|
||
color: Colors.transparent,
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(top: 6),
|
||
child: child,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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)),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Handy: Läuft ein Anruf, während ein Chat offen ist, zeigt diese Leiste den
|
||
/// Weg zurück (am PC übernimmt das die Anrufleiste in der Seitenleiste).
|
||
/// Vorher war im Chat nirgends zu sehen, dass/wie man zurück in den Anruf
|
||
/// kommt (Bernd 2026-10-07).
|
||
class _ActiveCallBar extends ConsumerWidget {
|
||
const _ActiveCallBar();
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
if (!isMobileLayout(MediaQuery.sizeOf(context))) return const SizedBox.shrink();
|
||
final voip = ref.watch(callSignalingProvider);
|
||
final lk = ref.watch(voiceChannelProvider);
|
||
final voipCall = voip.currentCall;
|
||
final String? name;
|
||
// Klingeln zählt nicht als „läuft“ – dafür gibt es das Annehmen-Fenster.
|
||
if (voipCall != null &&
|
||
voipCall.state != CallState.kEnded &&
|
||
voipCall.state != CallState.kRinging) {
|
||
name = voipCall.room.getLocalizedDisplayname();
|
||
} else if (lk.isActive || lk.isConnecting) {
|
||
name = lk.displayName;
|
||
} else {
|
||
name = null;
|
||
}
|
||
final pt = PyramidTheme.of(context);
|
||
return AnimatedSize(
|
||
duration: motionDuration(context, 220),
|
||
curve: Curves.easeOutCubic,
|
||
child: name == null
|
||
? const SizedBox(width: double.infinity)
|
||
: Material(
|
||
color: pt.online.withValues(alpha: 0.16),
|
||
child: InkWell(
|
||
onTap: () =>
|
||
ref.read(viewModeProvider.notifier).state = ViewMode.voice,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.call_rounded, size: 18, color: pt.online),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
'Anruf läuft · $name',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: pt.fg,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
Text('Zurück',
|
||
style: TextStyle(
|
||
color: pt.online,
|
||
fontSize: 13.5,
|
||
fontWeight: FontWeight.w700)),
|
||
Icon(Icons.chevron_right_rounded, size: 20, color: pt.online),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|