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 _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 _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; if (voipCall != null && voipCall.state != CallState.kEnded) { 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), ], ), ), ), ), ); } }