wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)

6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.)
als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen
Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
This commit is contained in:
Bernd Steckmeister
2026-07-03 05:47:18 +02:00
parent d706ace35f
commit 25ed765a03
195 changed files with 47784 additions and 1236 deletions
+534
View File
@@ -0,0 +1,534 @@
import 'package:flutter/material.dart';
import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/theme.dart';
Future<void> showBootstrapDialog(
BuildContext context,
Client client, {
bool force = false,
}) async {
if (client.encryption == null) return;
if (!force) {
final enc = client.encryption!;
final cached = await enc.keyManager.isCached().catchError((_) => false);
if (cached && enc.crossSigning.enabled) return;
}
if (!context.mounted) return;
await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => _BootstrapDialog(client: client),
);
}
class _BootstrapDialog extends StatefulWidget {
final Client client;
const _BootstrapDialog({required this.client});
@override
State<_BootstrapDialog> createState() => _BootstrapDialogState();
}
class _BootstrapDialogState extends State<_BootstrapDialog> {
Bootstrap? _bootstrap;
final _keyCtrl = TextEditingController();
bool _keyLoading = false;
String? _keyError;
bool _wipe = false;
@override
void initState() {
super.initState();
_start();
}
@override
void dispose() {
_keyCtrl.dispose();
super.dispose();
}
Future<void> _start() async {
final client = widget.client;
await client.roomsLoading;
await client.accountDataLoading;
await client.userDeviceKeysLoading;
while (client.prevBatch == null) {
await client.onSyncStatus.stream.first;
}
if (!mounted) return;
setState(() {
_bootstrap = client.encryption!.bootstrap(
onUpdate: (_) {
if (mounted) setState(() {});
},
);
});
}
void _autoAdvance(Bootstrap bs) {
switch (bs.state) {
case BootstrapState.askWipeSsss:
WidgetsBinding.instance
.addPostFrameCallback((_) => bs.wipeSsss(_wipe));
case BootstrapState.askBadSsss:
WidgetsBinding.instance
.addPostFrameCallback((_) => bs.ignoreBadSecrets(true));
case BootstrapState.askUseExistingSsss:
WidgetsBinding.instance
.addPostFrameCallback((_) => bs.useExistingSsss(!_wipe));
case BootstrapState.askUnlockSsss:
// Handled by UI if oldSsssKeys exist, otherwise skip
WidgetsBinding.instance
.addPostFrameCallback((_) => bs.unlockedSsss());
case BootstrapState.askNewSsss:
WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss());
case BootstrapState.askWipeCrossSigning:
WidgetsBinding.instance
.addPostFrameCallback((_) => bs.wipeCrossSigning(_wipe));
case BootstrapState.askSetupCrossSigning:
WidgetsBinding.instance.addPostFrameCallback(
(_) => bs.askSetupCrossSigning(
setupMasterKey: true,
setupSelfSigningKey: true,
setupUserSigningKey: true,
),
);
case BootstrapState.askWipeOnlineKeyBackup:
WidgetsBinding.instance
.addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(_wipe));
case BootstrapState.askSetupOnlineKeyBackup:
WidgetsBinding.instance
.addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true));
default:
break;
}
}
Future<void> _unlock() async {
final bs = _bootstrap;
if (bs == null || bs.newSsssKey == null) return;
final key = _keyCtrl.text.trim();
if (key.isEmpty) {
setState(() => _keyError = 'Bitte Wiederherstellungsschlüssel eingeben.');
return;
}
setState(() {
_keyLoading = true;
_keyError = null;
});
try {
await bs.newSsssKey!.unlock(keyOrPassphrase: key);
Logs().d('[Bootstrap] SSSS key unlocked');
await bs.openExistingSsss();
Logs().d('[Bootstrap] SSSS opened, secrets cached');
if (bs.encryption.crossSigning.enabled) {
await widget.client.encryption!.crossSigning.selfSign(recoveryKey: key);
Logs().d('[Bootstrap] Cross-signing self-signed');
}
} on InvalidPassphraseException catch (_) {
setState(
() => _keyError = 'Falscher Schlüssel. Bitte nochmal prüfen.',
);
} on FormatException catch (_) {
setState(() => _keyError = 'Ungültiges Format. Prüfe deinen Schlüssel.');
} catch (e) {
setState(() => _keyError = e.toString().split('\n').first);
} finally {
if (mounted) setState(() => _keyLoading = false);
}
}
Future<void> _close({required bool success}) async {
if (success) {
// Download all megolm session keys from backup — bypasses
// _requestedSessionIds deduplication so previously failed decryptions
// get a second chance.
widget.client.encryption?.keyManager
.loadAllKeys()
.catchError((e) => Logs().e('[Bootstrap] loadAllKeys failed', e));
}
if (mounted) Navigator.of(context).pop(success);
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final bs = _bootstrap;
// Waiting for bootstrap to initialise
if (bs == null || bs.state == BootstrapState.loading) {
return _Shell(
pt: pt,
title: 'Verschlüsselung einrichten',
onClose: () => _close(success: false),
child: const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator.adaptive(),
),
),
);
}
// User must enter recovery key
if (bs.state == BootstrapState.openExistingSsss) {
return _Shell(
pt: pt,
title: 'Nachrichten entschlüsseln',
onClose: () => _close(success: false),
child: _KeyInputBody(
pt: pt,
ctrl: _keyCtrl,
loading: _keyLoading,
error: _keyError,
onUnlock: _unlock,
onWipe: () {
setState(() {
_wipe = true;
_start();
});
},
),
);
}
// Success — all done
if (bs.state == BootstrapState.done) {
return _Shell(
pt: pt,
title: 'Einrichtung abgeschlossen',
onClose: null,
child: _DoneBody(
pt: pt,
onClose: () => _close(success: true),
),
);
}
// Error
if (bs.state == BootstrapState.error) {
return _Shell(
pt: pt,
title: 'Fehler',
onClose: () => _close(success: false),
child: _ErrorBody(
pt: pt,
onClose: () => _close(success: false),
),
);
}
// Auto-advance all other (non-interactive) states
_autoAdvance(bs);
return _Shell(
pt: pt,
title: 'Verschlüsselung einrichten…',
onClose: () => _close(success: false),
child: const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator.adaptive(),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Sub-widgets
// ─────────────────────────────────────────────────────────────────────────────
class _Shell extends StatelessWidget {
final PyramidTheme pt;
final String title;
final VoidCallback? onClose;
final Widget child;
const _Shell({
required this.pt,
required this.title,
required this.onClose,
required this.child,
});
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase + 4),
),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 460,
minWidth: 320,
minHeight: 120,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 12, 12),
child: Row(
children: [
Icon(Icons.shield_outlined, size: 18, color: pt.accent),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
if (onClose != null)
IconButton(
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
onPressed: onClose,
tooltip: 'Überspringen',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
),
],
),
),
Divider(color: pt.border, height: 1),
child,
],
),
),
);
}
}
class _KeyInputBody extends StatelessWidget {
final PyramidTheme pt;
final TextEditingController ctrl;
final bool loading;
final String? error;
final VoidCallback onUnlock;
final VoidCallback onWipe;
const _KeyInputBody({
required this.pt,
required this.ctrl,
required this.loading,
required this.error,
required this.onUnlock,
required this.onWipe,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Gib deinen Wiederherstellungsschlüssel ein, um deine verschlüsselten Nachrichten lesbar zu machen.',
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
),
const SizedBox(height: 16),
TextField(
controller: ctrl,
readOnly: loading,
autofocus: true,
autocorrect: false,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontFamily: 'monospace',
),
decoration: InputDecoration(
hintText: 'EsXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 12),
labelText: 'Wiederherstellungsschlüssel',
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
errorText: error,
errorMaxLines: 2,
filled: true,
fillColor: pt.bg2,
prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.accent),
),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
cursorColor: pt.accent,
onSubmitted: (_) => onUnlock(),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
onPressed: loading ? null : onUnlock,
icon: loading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
color: pt.accentFg,
strokeWidth: 2,
),
)
: const Icon(Icons.lock_open_outlined, size: 18),
label: Text(
loading ? 'Entschlüssele…' : 'Nachrichten entschlüsseln',
),
),
),
const SizedBox(height: 12),
// Divider + lost key option
Row(
children: [
Expanded(child: Divider(color: pt.border)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text('oder', style: TextStyle(color: pt.fgDim, fontSize: 12)),
),
Expanded(child: Divider(color: pt.border)),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: PyramidColors.danger,
side: BorderSide(color: PyramidColors.danger.withAlpha(100)),
padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: loading ? null : onWipe,
icon: const Icon(Icons.delete_outlined, size: 16),
label: const Text('Schlüssel verloren — neu einrichten', style: TextStyle(fontSize: 13)),
),
),
],
),
);
}
}
class _DoneBody extends StatelessWidget {
final PyramidTheme pt;
final VoidCallback onClose;
const _DoneBody({required this.pt, required this.onClose});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle_outline_rounded,
color: PyramidColors.success,
size: 56,
),
const SizedBox(height: 12),
Text(
'Alle Schlüssel wurden erfolgreich importiert.',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 6),
Text(
'Deine verschlüsselten Nachrichten werden jetzt im Hintergrund entschlüsselt.',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
),
onPressed: onClose,
child: const Text('Fertig'),
),
),
],
),
);
}
}
class _ErrorBody extends StatelessWidget {
final PyramidTheme pt;
final VoidCallback onClose;
const _ErrorBody({required this.pt, required this.onClose});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48),
const SizedBox(height: 12),
Text(
'Etwas ist schiefgelaufen. Versuche es erneut.',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: pt.fg,
side: BorderSide(color: pt.border),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: onClose,
child: const Text('Schließen'),
),
),
],
),
);
}
}
+6
View File
@@ -1,5 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart' as mx;
import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -74,6 +78,8 @@ class LoginNotifier extends StateNotifier<LoginFormState> {
initialDeviceDisplayName: deviceName ?? 'Pyramid',
);
if (Platform.isAndroid) unawaited(initFcm(client));
state = state.copyWith(status: LoginStatus.idle);
} on mx.MatrixException catch (e) {
state = state.copyWith(
+470
View File
@@ -0,0 +1,470 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/livekit_call_manager.dart';
import 'package:pyramid/core/voip_manager.dart';
import 'package:pyramid/widgets/screen_share_picker.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/livekit_client.dart';
class MiniCallWidget extends ConsumerStatefulWidget {
final dynamic call; // Can be LiveKitCallManager or PyramidVoipManager
const MiniCallWidget({super.key, required this.call});
@override
ConsumerState<MiniCallWidget> createState() => _MiniCallWidgetState();
}
class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
int _elapsed = 0;
Timer? _timer;
bool _streamCollapsed = false;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _elapsed++);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
String _fmt(int s) {
final m = s ~/ 60;
final sec = s % 60;
return '${m.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final isVoip = widget.call is PyramidVoipManager;
final call = widget.call;
final String displayName = isVoip
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unknown')
: call.displayName;
final bool isConnecting = isVoip ? false : call.isConnecting;
final bool isMuted = isVoip ? call.isMicMuted : call.isMuted;
final bool isCameraOff = isVoip ? call.isCameraMuted : call.isCameraOff;
final bool isDeafened = isVoip ? call.isDeafened : call.isDeafened;
final bool isStreaming = isVoip ? call.isScreensharing : call.isScreenSharing;
// Show stream preview if camera is on or screensharing
final showPreview = !isCameraOff || isStreaming;
// Im Querformat auf Mobilgeräten ist die Höhe knapp — Vorschau kompakter.
final compact = MediaQuery.sizeOf(context).height < 500;
final previewHeight = _streamCollapsed ? 28.0 : (compact ? 84.0 : 140.0);
return Container(
decoration: BoxDecoration(
color: pt.bg2,
border: Border(top: BorderSide(color: pt.border)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Stream Mini Preview (Mockup style)
if (showPreview)
AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
height: previewHeight,
width: double.infinity,
decoration: const BoxDecoration(
color: Colors.black,
border: Border(bottom: BorderSide(color: Colors.black26)),
),
child: Stack(
children: [
// Stream Content
if (!_streamCollapsed)
Positioned.fill(
child: isVoip
? rtc.RTCVideoView(
// When we're screensharing show our local stream,
// otherwise show the remote participant's stream
isStreaming ? call.localRenderer : call.remoteRenderer,
objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
)
: _LiveKitPreview(manager: call as LiveKitCallManager),
),
// Top bar of preview
Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black.withAlpha(200), Colors.transparent],
),
),
child: Row(
children: [
const _LiveDot(),
const SizedBox(width: 6),
Expanded(
child: Text(
isStreaming ? 'STREAMING' : 'CAMERA ON',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.5),
),
),
GestureDetector(
onTap: () => setState(() => _streamCollapsed = !_streamCollapsed),
child: Icon(
_streamCollapsed ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded,
size: 16,
color: Colors.white70,
),
),
],
),
),
],
),
),
// Connected info row
GestureDetector(
onTap: () => ref.read(viewModeProvider.notifier).state = ViewMode.voice,
child: Container(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
pt.online.withAlpha(46),
Colors.transparent,
],
end: Alignment.centerRight,
),
border: Border(bottom: BorderSide(color: pt.border)),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const _LiveDot(),
const SizedBox(width: 6),
Text(
isConnecting ? 'CONNECTING...' : 'VOICE CONNECTED',
style: TextStyle(
color: pt.online,
fontSize: 10,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
],
),
const SizedBox(height: 2),
Text(
displayName,
style: TextStyle(
color: pt.fg,
fontSize: 12,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
Text(
_fmt(_elapsed),
style: TextStyle(
color: pt.fgDim,
fontSize: 10,
fontFamily: 'monospace',
),
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_ActionIcon(
icon: Icons.open_in_new_rounded,
pt: pt,
onTap: () => ref.read(viewModeProvider.notifier).state = ViewMode.voice,
),
_ActionIcon(
icon: Icons.call_end_rounded,
pt: pt,
danger: true,
onTap: () {
if (isVoip) {
call.hangup();
} else {
call.hangUp();
}
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
},
),
],
),
],
),
),
),
// Controls
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
_ControlBtn(
icon: isMuted ? Icons.mic_off_rounded : Icons.mic_none_rounded,
active: !isMuted,
danger: isMuted,
pt: pt,
onTap: () {
if (isVoip) {
call.toggleMic();
} else {
call.toggleMute();
}
},
),
const SizedBox(width: 4),
_ControlBtn(
icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded,
active: !isDeafened,
danger: isDeafened,
pt: pt,
onTap: () => call.toggleDeafen(),
),
const SizedBox(width: 4),
_ControlBtn(
icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded,
active: !isCameraOff,
pt: pt,
onTap: () => call.toggleCamera(),
),
const SizedBox(width: 4),
_ControlBtn(
icon: Icons.screen_share_rounded,
active: isStreaming,
pt: pt,
onTap: () async {
if (isVoip) {
(call as PyramidVoipManager).toggleScreenSharing(context);
} else {
final lk = call as LiveKitCallManager;
if (lk.isScreenSharing) {
await lk.stopScreenShare();
} else {
final source = await ScreenSharePicker.show(context);
if (source != null) {
await lk.startScreenShare(source.id);
}
}
}
},
),
],
),
),
],
),
);
}
}
class _ActionIcon extends StatelessWidget {
final IconData icon;
final PyramidTheme pt;
final VoidCallback onTap;
final bool danger;
const _ActionIcon({required this.icon, required this.pt, required this.onTap, this.danger = false});
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(icon, size: 18),
onPressed: onTap,
color: danger ? pt.danger : pt.fgDim,
visualDensity: VisualDensity.compact,
style: danger ? IconButton.styleFrom(
hoverColor: pt.danger.withAlpha(30),
) : null,
);
}
}
class _ControlBtn extends StatefulWidget {
final IconData icon;
final bool active;
final bool danger;
final PyramidTheme pt;
final VoidCallback onTap;
const _ControlBtn({required this.icon, required this.active, required this.pt, required this.onTap, this.danger = false});
@override
State<_ControlBtn> createState() => _ControlBtnState();
}
class _ControlBtnState extends State<_ControlBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Expanded(
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: pt.durationFast,
height: 32,
decoration: BoxDecoration(
color: widget.active ? (widget.danger ? pt.danger : pt.accent) : pt.bg3,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Icon(
widget.icon,
size: 16,
color: widget.active ? (widget.danger ? Colors.white : pt.accentFg) : pt.fg,
),
),
),
),
);
}
}
class _LiveKitPreview extends StatelessWidget {
final LiveKitCallManager manager;
const _LiveKitPreview({required this.manager});
@override
Widget build(BuildContext context) {
final room = manager.room;
if (room == null) return const SizedBox();
// Find first available video track (screenshare preferred)
VideoTrack? bestTrack;
// Check remote participants
for (final p in room.remoteParticipants.values) {
for (final pub in p.videoTrackPublications) {
if (pub.source == TrackSource.screenShareVideo) {
final t = pub.track;
if (t is VideoTrack) {
bestTrack = t;
break;
}
}
}
if (bestTrack != null) break;
}
// Fallback to camera if no screen share
if (bestTrack == null) {
for (final p in room.remoteParticipants.values) {
for (final pub in p.videoTrackPublications) {
if (pub.source == TrackSource.camera) {
final t = pub.track;
if (t is VideoTrack) {
bestTrack = t;
break;
}
}
}
if (bestTrack != null) break;
}
}
// Fallback to local if still nothing
if (bestTrack == null && room.localParticipant != null) {
for (final pub in room.localParticipant!.videoTrackPublications) {
final t = pub.track;
if (t is VideoTrack) {
bestTrack = t;
break;
}
}
}
if (bestTrack == null) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.indigo.shade900, Colors.blue.shade900],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: const Center(
child: Icon(Icons.podcasts_rounded, color: Colors.white24, size: 40),
),
);
}
return VideoTrackRenderer(
bestTrack,
fit: VideoViewFit.cover,
);
}
}
class _LiveDot extends StatefulWidget {
const _LiveDot();
@override
State<_LiveDot> createState() => _LiveDotState();
}
class _LiveDotState extends State<_LiveDot> with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2))..repeat(reverse: true);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return AnimatedBuilder(
animation: _ctrl,
builder: (context, _) => Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: pt.online,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: pt.online.withAlpha((_ctrl.value * 150).toInt()),
blurRadius: 4 * _ctrl.value,
spreadRadius: 2 * _ctrl.value,
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:mime/mime.dart';
import 'package:pyramid/core/theme.dart';
class AttachmentDialog extends StatefulWidget {
final List<File> files;
final Room room;
final Event? replyTo;
const AttachmentDialog({
super.key,
required this.files,
required this.room,
this.replyTo,
});
static Future<bool> show(
BuildContext context,
List<File> files,
Room room, {
Event? replyTo,
}) async {
// Snappy Scale+Fade-Eingangsanimation statt Standard-Einblendung.
return await showGeneralDialog<bool>(
context: context,
barrierDismissible: true,
barrierLabel: 'Anhang',
barrierColor: Colors.black54,
transitionDuration: const Duration(milliseconds: 180),
pageBuilder: (_, __, ___) =>
AttachmentDialog(files: files, room: room, replyTo: replyTo),
transitionBuilder: (_, anim, __, child) {
final curved =
CurvedAnimation(parent: anim, curve: Curves.easeOutBack);
return FadeTransition(
opacity: anim,
child: ScaleTransition(scale: Tween(begin: 0.92, end: 1.0).animate(curved), child: child),
);
},
) ??
false;
}
@override
State<AttachmentDialog> createState() => _AttachmentDialogState();
}
class _AttachmentDialogState extends State<AttachmentDialog> {
final _msgCtrl = TextEditingController();
bool _sending = false;
String? _error;
bool _compress = false;
bool get _hasImages => widget.files.any((f) {
final mime = lookupMimeType(f.path.split(Platform.pathSeparator).last) ?? '';
return mime.startsWith('image/');
});
@override
void dispose() {
_msgCtrl.dispose();
super.dispose();
}
Future<void> _send() async {
setState(() { _sending = true; _error = null; });
try {
for (final file in widget.files) {
final bytes = await file.readAsBytes();
final filename = file.path.split(Platform.pathSeparator).last;
final mimeType = lookupMimeType(filename) ?? 'application/octet-stream';
final matrixFile = MatrixFile(bytes: bytes, name: filename, mimeType: mimeType);
final isImage = mimeType.startsWith('image/');
await widget.room.sendFileEvent(
matrixFile,
inReplyTo: widget.replyTo,
shrinkImageMaxDimension: (_compress && isImage) ? 1600 : null,
);
}
if (_msgCtrl.text.trim().isNotEmpty) {
await widget.room.sendTextEvent(
_msgCtrl.text.trim(),
inReplyTo: widget.replyTo,
);
}
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
setState(() { _sending = false; _error = e.toString().split('\n').first; });
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return Dialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rLg),
side: BorderSide(color: pt.border),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, minWidth: 320),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Row(
children: [
Icon(Icons.attach_file_rounded, size: 18, color: pt.accent),
const SizedBox(width: 8),
Text(
'${widget.files.length} Datei${widget.files.length == 1 ? '' : 'en'} senden',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600),
),
const Spacer(),
GestureDetector(
onTap: () => Navigator.of(context).pop(false),
child: Icon(Icons.close_rounded, size: 18, color: pt.fgDim),
),
],
),
const SizedBox(height: 16),
// File list
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 160),
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.files.length,
itemBuilder: (ctx, i) => _FilePreview(file: widget.files[i], pt: pt),
),
),
const SizedBox(height: 16),
// Caption field
TextField(
controller: _msgCtrl,
style: TextStyle(color: pt.fg, fontSize: 14),
cursorColor: pt.accent,
maxLines: 3,
minLines: 1,
decoration: InputDecoration(
hintText: 'Nachricht hinzufügen (optional)',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 14),
filled: true,
fillColor: pt.bg2,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rBase),
borderSide: BorderSide(color: pt.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rBase),
borderSide: BorderSide(color: pt.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rBase),
borderSide: BorderSide(color: pt.accent),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
isDense: true,
),
),
// Image quality toggle
if (_hasImages) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: Row(
children: [
Icon(Icons.high_quality_outlined, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Text('Hohe Qualität', style: TextStyle(color: pt.fg, fontSize: 13)),
const Spacer(),
Switch.adaptive(
value: !_compress,
onChanged: (v) => setState(() => _compress = !v),
activeColor: pt.accent,
),
],
),
),
],
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: pt.danger, fontSize: 12)),
],
const SizedBox(height: 16),
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _sending ? null : () => Navigator.of(context).pop(false),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _sending ? null : _send,
style: FilledButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase),
),
),
icon: _sending
? SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: pt.accentFg,
),
)
: const Icon(Icons.send_rounded, size: 14),
label: const Text('Senden'),
),
],
),
],
),
),
),
);
}
}
class _FilePreview extends StatefulWidget {
final File file;
final PyramidTheme pt;
const _FilePreview({required this.file, required this.pt});
@override
State<_FilePreview> createState() => _FilePreviewState();
}
class _FilePreviewState extends State<_FilePreview> {
int? _bytes;
@override
void initState() {
super.initState();
widget.file.length().then((b) { if (mounted) setState(() => _bytes = b); });
}
@override
Widget build(BuildContext context) {
final filename = widget.file.path.split(Platform.pathSeparator).last;
final mime = lookupMimeType(filename) ?? 'application/octet-stream';
final isImage = mime.startsWith('image/');
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: widget.pt.bg2,
borderRadius: BorderRadius.circular(widget.pt.rBase),
border: Border.all(color: widget.pt.border),
),
child: Row(
children: [
if (isImage)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.file(widget.file, width: 40, height: 40, fit: BoxFit.cover),
)
else
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: widget.pt.bg3,
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Icon(_mimeIcon(mime), size: 20, color: widget.pt.fgDim),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
filename,
style: TextStyle(color: widget.pt.fg, fontSize: 13),
overflow: TextOverflow.ellipsis,
),
if (_bytes != null)
Text(
_formatSize(_bytes!),
style: TextStyle(color: widget.pt.fgDim, fontSize: 11),
),
],
),
),
],
),
);
}
IconData _mimeIcon(String mime) {
if (mime.startsWith('video/')) return Icons.videocam_outlined;
if (mime.startsWith('audio/')) return Icons.audio_file_outlined;
if (mime.contains('pdf')) return Icons.picture_as_pdf_outlined;
if (mime.contains('zip') || mime.contains('tar') || mime.contains('gz')) {
return Icons.folder_zip_outlined;
}
return Icons.insert_drive_file_outlined;
}
String _formatSize(int bytes) {
if (bytes < 1024) return '${bytes} B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
+630
View File
@@ -0,0 +1,630 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/attachment_dialog.dart';
import 'package:pyramid/features/chat/emoji_picker.dart';
import 'package:pyramid/features/chat/gif_sticker_picker.dart';
import 'package:pyramid/utils/clipboard_image.dart';
class ChatComposer extends ConsumerStatefulWidget {
final Room room;
final Event? replyTo;
final VoidCallback? onClearReply;
final VoidCallback? onOpenEmoji;
const ChatComposer({
super.key,
required this.room,
this.replyTo,
this.onClearReply,
this.onOpenEmoji,
});
@override
ConsumerState<ChatComposer> createState() => _ChatComposerState();
}
class _ChatComposerState extends ConsumerState<ChatComposer> {
final _ctrl = TextEditingController();
late final FocusNode _focus;
bool _canSend = false;
OverlayEntry? _gifOverlay;
OverlayEntry? _emojiOverlay;
Timer? _typingTimer;
bool _isTyping = false;
@override
void initState() {
super.initState();
_focus = FocusNode(onKeyEvent: _handleKeyEvent);
_ctrl.addListener(_onTextChanged);
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.keyV &&
HardwareKeyboard.instance.isControlPressed) {
final bytes = getClipboardImageBytes();
if (bytes != null) {
_pasteClipboardImage(bytes);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
Future<void> _pasteClipboardImage(Uint8List bytes) async {
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/paste_${DateTime.now().millisecondsSinceEpoch}.png');
await file.writeAsBytes(bytes);
if (!mounted) return;
await AttachmentDialog.show(context, [file], widget.room, replyTo: widget.replyTo);
widget.onClearReply?.call();
file.delete().catchError((_) => file);
}
/// Bilder, die die Android-Tastatur einfügt (Gboard-Clipboard-Chip,
/// GIF-Auswahl der Tastatur etc.) — landen im Anhang-Dialog mit Vorschau.
Future<void> _handleInsertedContent(KeyboardInsertedContent content) async {
final bytes = content.data;
if (bytes == null || bytes.isEmpty) return;
final ext = content.mimeType.contains('/')
? content.mimeType.split('/').last
: 'png';
final dir = await getTemporaryDirectory();
final file = File(
'${dir.path}/keyboard_${DateTime.now().millisecondsSinceEpoch}.$ext');
await file.writeAsBytes(bytes);
if (!mounted) return;
await AttachmentDialog.show(context, [file], widget.room, replyTo: widget.replyTo);
widget.onClearReply?.call();
file.delete().catchError((_) => file);
}
void _onTextChanged() {
final has = _ctrl.text.trim().isNotEmpty;
if (has != _canSend) setState(() => _canSend = has);
_updateTyping(has);
}
void _updateTyping(bool typing) {
final sendTyping = ref.read(privacyTypingProvider);
if (!sendTyping) return;
if (typing) {
// Reset the auto-stop timer on every keystroke.
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 7), () => _sendTyping(false));
if (!_isTyping) _sendTyping(true);
} else {
_typingTimer?.cancel();
if (_isTyping) _sendTyping(false);
}
}
void _sendTyping(bool typing) {
_isTyping = typing;
widget.room
.setTyping(typing, timeout: typing ? 10000 : null)
.catchError((_) {});
}
@override
void dispose() {
_typingTimer?.cancel();
if (_isTyping) widget.room.setTyping(false).catchError((_) {});
_gifOverlay?.remove();
_emojiOverlay?.remove();
_ctrl.dispose();
_focus.dispose();
super.dispose();
}
void _toggleGifPicker() {
if (_gifOverlay != null) {
_gifOverlay!.remove();
_gifOverlay = null;
return;
}
final platform = Theme.of(context).platform;
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS;
if (isMobile) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
top: 40,
),
child: GifStickerPicker(
room: widget.room,
onClose: () => Navigator.of(ctx).pop(),
),
),
);
return;
}
final box = context.findRenderObject() as RenderBox?;
final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;
final size = box?.size ?? Size.zero;
_gifOverlay = OverlayEntry(builder: (ctx) {
final screenSize = MediaQuery.sizeOf(ctx);
const pickerW = 380.0;
const pickerH = 460.0;
final left = (offset.dx + size.width - pickerW).clamp(8.0, screenSize.width - pickerW - 8);
final top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8);
return Material(
type: MaterialType.transparency,
child: Stack(children: [
GestureDetector(
onTap: () { _gifOverlay?.remove(); _gifOverlay = null; },
behavior: HitTestBehavior.translucent,
child: const SizedBox.expand(),
),
Positioned(
left: left,
top: top,
child: GifStickerPicker(
room: widget.room,
onClose: () { _gifOverlay?.remove(); _gifOverlay = null; },
),
),
]),
);
});
Overlay.of(context).insert(_gifOverlay!);
}
void _toggleEmojiPicker() {
if (_emojiOverlay != null) {
_emojiOverlay!.remove();
_emojiOverlay = null;
return;
}
final platform = Theme.of(context).platform;
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS;
void onEmojiSelected(String emoji, BuildContext? ctx) {
final pos = _ctrl.selection.baseOffset;
final text = _ctrl.text;
final before = pos < 0 ? text : text.substring(0, pos);
final after = pos < 0 ? '' : text.substring(pos);
_ctrl.value = TextEditingValue(
text: '$before$emoji$after',
selection: TextSelection.collapsed(
offset: before.length + emoji.length,
),
);
if (isMobile && ctx != null) {
Navigator.of(ctx).pop();
} else {
_emojiOverlay?.remove();
_emojiOverlay = null;
}
_focus.requestFocus();
}
if (isMobile) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
top: 40,
),
child: EmojiPicker(
onEmojiSelected: (emoji) => onEmojiSelected(emoji, ctx),
onClose: () => Navigator.of(ctx).pop(),
),
),
);
return;
}
final box = context.findRenderObject() as RenderBox?;
final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;
final size = box?.size ?? Size.zero;
_emojiOverlay = OverlayEntry(builder: (ctx) {
final screenSize = MediaQuery.sizeOf(ctx);
const pickerW = 340.0;
const pickerH = 380.0;
final left = (offset.dx + size.width - pickerW).clamp(8.0, screenSize.width - pickerW - 8);
final top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8);
return Material(
type: MaterialType.transparency,
child: Stack(children: [
GestureDetector(
onTap: () { _emojiOverlay?.remove(); _emojiOverlay = null; },
behavior: HitTestBehavior.translucent,
child: const SizedBox.expand(),
),
Positioned(
left: left,
top: top,
child: EmojiPicker(
onEmojiSelected: (emoji) => onEmojiSelected(emoji, null),
onClose: () { _emojiOverlay?.remove(); _emojiOverlay = null; },
),
),
]),
);
});
Overlay.of(context).insert(_emojiOverlay!);
}
Future<void> _pickAttachment() async {
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null || result.files.isEmpty) return;
if (!mounted) return;
final files = result.files
.where((f) => f.path != null)
.map((f) => File(f.path!))
.toList();
if (files.isEmpty) return;
await AttachmentDialog.show(context, files, widget.room, replyTo: widget.replyTo);
widget.onClearReply?.call();
}
Future<void> _send() async {
final text = _ctrl.text.trim();
if (text.isEmpty) return;
_ctrl.clear();
setState(() => _canSend = false);
// Stop typing indicator immediately before sending.
_typingTimer?.cancel();
if (_isTyping) _sendTyping(false);
await widget.room.sendTextEvent(text, inReplyTo: widget.replyTo);
widget.onClearReply?.call();
_focus.requestFocus();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final size = MediaQuery.sizeOf(context);
final isSmall = size.width < 400;
final roomName = widget.room.getLocalizedDisplayname();
final placeholder = widget.room.isDirectChat
? 'Message…'
: 'Message #$roomName';
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.replyTo != null)
_ReplyBar(
event: widget.replyTo!,
onClear: widget.onClearReply ?? () {},
pt: pt,
),
Padding(
// Bottom inset matches the profile block in the rooms list (8px) so
// the composer sits flush with it.
padding: EdgeInsets.fromLTRB(isSmall ? 8 : 16, 0, isSmall ? 8 : 16, 8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
// Tuned so the single-line composer is flush with the profile
// block in the rooms list (~89px on the user's display). Grows for
// multiline.
constraints: const BoxConstraints(minHeight: 53),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase + 4),
border: Border.all(
color: _focus.hasFocus ? pt.accent : pt.border,
),
boxShadow: _focus.hasFocus
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
: null,
),
child: Focus(
onFocusChange: (_) => setState(() {}),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Attach button
_ComposerIconBtn(
icon: Icons.add_rounded,
tooltip: 'Datei anhängen',
pt: pt,
size: size.width < 600 ? 24 : 30,
onTap: _pickAttachment,
),
// Text input
Expanded(
child: TextField(
controller: _ctrl,
focusNode: _focus,
minLines: 1,
maxLines: 8,
style: TextStyle(color: pt.fg, fontSize: 14),
cursorColor: pt.accent,
decoration: InputDecoration(
hintText: placeholder,
hintStyle: TextStyle(color: pt.fgDim, fontSize: 14),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: size.width < 600 ? 4 : 8,
vertical: 10,
),
isDense: true,
),
inputFormatters: [
if (!(Platform.isAndroid || Platform.isIOS))
_SendOnEnterFormatter(onSend: _send),
],
// Meldet der Android-Tastatur, dass dieses Feld Bilder
// annimmt — dadurch bietet z.B. Gboard frisch kopierte
// Bilder direkt in der Vorschlagsleiste an.
contentInsertionConfiguration: Platform.isAndroid
? ContentInsertionConfiguration(
allowedMimeTypes: const [
'image/gif',
'image/png',
'image/jpeg',
'image/webp',
],
onContentInserted: _handleInsertedContent,
)
: null,
),
),
// Inline tools
Row(
mainAxisSize: MainAxisSize.min,
children: [
_ComposerIconBtn(
icon: Icons.gif_box_outlined,
tooltip: 'GIF',
pt: pt,
size: size.width < 600 ? 22 : 26,
onTap: _toggleGifPicker,
),
_ComposerIconBtn(
icon: Icons.tag_faces_rounded,
tooltip: 'Emoji',
pt: pt,
size: size.width < 600 ? 22 : 26,
onTap: _toggleEmojiPicker,
),
if (size.width > 600)
_ComposerIconBtn(
icon: Icons.alternate_email_rounded,
tooltip: 'Mention',
pt: pt,
size: 26,
),
],
),
// Send button — only on mobile
if (Platform.isAndroid || Platform.isIOS)
Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 8, 4),
child: _SendButton(
canSend: _canSend,
pt: pt,
onTap: _send,
),
),
],
),
),
),
),
],
),
);
}
}
class _ComposerIconBtn extends StatefulWidget {
final IconData icon;
final String tooltip;
final PyramidTheme pt;
final double size;
final VoidCallback? onTap;
const _ComposerIconBtn({
required this.icon,
required this.tooltip,
required this.pt,
required this.size,
this.onTap,
});
@override
State<_ComposerIconBtn> createState() => _ComposerIconBtnState();
}
class _ComposerIconBtnState extends State<_ComposerIconBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final platform = Theme.of(context).platform;
final isMobile =
platform == TargetPlatform.android || platform == TargetPlatform.iOS;
final touchSize = isMobile ? 44.0 : widget.size;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Tooltip(
message: widget.tooltip,
waitDuration: const Duration(milliseconds: 500),
child: SizedBox(
width: touchSize,
height: touchSize,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: _hovered ? widget.pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Icon(
widget.icon,
size: widget.size * 0.55,
color: _hovered ? widget.pt.fg : widget.pt.fgMuted,
),
),
),
),
),
),
),
);
}
}
class _SendButton extends StatefulWidget {
final bool canSend;
final PyramidTheme pt;
final VoidCallback onTap;
const _SendButton({required this.canSend, required this.pt, required this.onTap});
@override
State<_SendButton> createState() => _SendButtonState();
}
class _SendButtonState extends State<_SendButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final active = widget.canSend;
return MouseRegion(
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: active ? widget.onTap : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutBack,
width: 36,
height: 36,
decoration: BoxDecoration(
color: active ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(pt.rSm),
boxShadow: active && _hovered
? [BoxShadow(color: pt.accentGlow, blurRadius: 12, offset: const Offset(0, 4))]
: null,
),
transform: active
? (_hovered
? (Matrix4.diagonal3Values(1.1, 1.1, 1.0)..rotateZ(-0.26))
: Matrix4.diagonal3Values(1.05, 1.05, 1.0))
: Matrix4.identity(),
transformAlignment: Alignment.center,
child: Center(
child: Icon(
Icons.send_rounded,
size: 16,
color: active ? pt.accentFg : pt.fgDim,
),
),
),
),
);
}
}
class _ReplyBar extends StatelessWidget {
final Event event;
final VoidCallback onClear;
final PyramidTheme pt;
const _ReplyBar({required this.event, required this.onClear, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 4),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: pt.border),
left: BorderSide(color: pt.accent, width: 3),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.senderFromMemoryOrFallback.calcDisplayname(),
style: TextStyle(
color: pt.accent,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
Text(
event.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: pt.fgMuted, fontSize: 12),
),
],
),
),
GestureDetector(
onTap: onClear,
child: Icon(Icons.close_rounded, size: 16, color: pt.fgDim),
),
],
),
);
}
}
class _SendOnEnterFormatter extends TextInputFormatter {
final VoidCallback onSend;
_SendOnEnterFormatter({required this.onSend});
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (newValue.text.endsWith('\n') && !oldValue.text.endsWith('\n')) {
// Check if shift is pressed (can't detect here, just send on enter)
// For multiline we'd need keyboard shortcuts
WidgetsBinding.instance.addPostFrameCallback((_) => onSend());
return oldValue;
}
return newValue;
}
}
-211
View File
@@ -1,211 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:matrix/matrix.dart';
class ChatInput extends StatefulWidget {
final Room room;
final Event? replyTo;
final VoidCallback? onClearReply;
const ChatInput({
super.key,
required this.room,
this.replyTo,
this.onClearReply,
});
@override
State<ChatInput> createState() => _ChatInputState();
}
class _ChatInputState extends State<ChatInput> {
final _ctrl = TextEditingController();
bool _canSend = false;
@override
void initState() {
super.initState();
_ctrl.addListener(() {
final hasText = _ctrl.text.trim().isNotEmpty;
if (hasText != _canSend) setState(() => _canSend = hasText);
});
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
Future<void> _send() async {
final text = _ctrl.text.trim();
if (text.isEmpty) return;
_ctrl.clear();
setState(() => _canSend = false);
await widget.room.sendTextEvent(text, inReplyTo: widget.replyTo);
widget.onClearReply?.call();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDesktop = !kIsWeb &&
(defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS);
// Desktop: taller bar, 4px square corners, bigger button
// Mobile: compact bar, pill shape, smaller button
final fieldRadius = isDesktop ? 4.0 : 20.0;
final btnSize = isDesktop ? 56.0 : 44.0;
final btnRadius = isDesktop ? 6.0 : 12.0;
final iconSize = isDesktop ? 24.0 : 20.0;
final fontSize = isDesktop ? 15.0 : 14.0;
final vPad = isDesktop ? 18.0 : 10.0;
final hPad = isDesktop ? 16.0 : 14.0;
final outerPad = isDesktop
? const EdgeInsets.fromLTRB(12, 10, 12, 12)
: const EdgeInsets.fromLTRB(8, 4, 8, 8);
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.replyTo != null)
_ReplyBar(
event: widget.replyTo!,
onClear: widget.onClearReply ?? () {},
),
Padding(
padding: outerPad,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _ctrl,
minLines: 1,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
style: TextStyle(fontSize: fontSize),
decoration: InputDecoration(
hintText: 'Nachricht...',
hintStyle: TextStyle(fontSize: fontSize),
contentPadding: EdgeInsets.symmetric(
horizontal: hPad,
vertical: vPad,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(fieldRadius),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(fieldRadius),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(fieldRadius),
borderSide: BorderSide.none,
),
filled: true,
fillColor: theme.colorScheme.surfaceContainerHigh,
),
onSubmitted: (_) => _send(),
keyboardType: TextInputType.multiline,
inputFormatters: [
_SendOnEnterFormatter(onSend: _send),
],
),
),
SizedBox(width: isDesktop ? 10 : 8),
GestureDetector(
onTap: _canSend ? _send : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: btnSize,
height: btnSize,
decoration: BoxDecoration(
color: _canSend
? theme.colorScheme.primary
: theme.colorScheme.primary.withAlpha(70),
borderRadius: BorderRadius.circular(btnRadius),
),
child: Center(
child: Icon(
Icons.send_rounded,
size: iconSize,
color: Colors.white,
),
),
),
),
],
),
),
],
),
);
}
}
class _ReplyBar extends StatelessWidget {
final Event event;
final VoidCallback onClear;
const _ReplyBar({required this.event, required this.onClear});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 4),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.dividerColor),
left: BorderSide(color: theme.colorScheme.primary, width: 3),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.senderFromMemoryOrFallback.calcDisplayname(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
Text(
event.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: onClear,
),
],
),
);
}
}
class _SendOnEnterFormatter extends TextInputFormatter {
final VoidCallback onSend;
_SendOnEnterFormatter({required this.onSend});
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
return newValue;
}
}
-156
View File
@@ -1,156 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/features/chat/chat_input.dart';
import 'package:pyramid/features/chat/chat_provider.dart';
import 'package:pyramid/features/chat/message_bubble.dart';
class ChatPage extends ConsumerStatefulWidget {
final String roomId;
const ChatPage({super.key, required this.roomId});
@override
ConsumerState<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends ConsumerState<ChatPage> {
final _scrollCtrl = ScrollController();
Event? _replyTo;
String get _roomId => Uri.decodeComponent(widget.roomId);
@override
void initState() {
super.initState();
_scrollCtrl.addListener(_onScroll);
}
@override
void dispose() {
_scrollCtrl.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollCtrl.position.pixels >=
_scrollCtrl.position.maxScrollExtent - 300) {
final timeline = ref.read(timelineProvider(_roomId)).valueOrNull;
timeline?.requestHistory();
}
}
@override
Widget build(BuildContext context) {
final room = ref.watch(roomProvider(_roomId));
final timelineAsync = ref.watch(timelineProvider(_roomId));
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/rooms'),
),
title: Text(room?.getLocalizedDisplayname() ?? _roomId),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {},
),
],
),
body: Column(
children: [
Expanded(
child: timelineAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Fehler: $e')),
data: (timeline) => _MessageList(
timeline: timeline,
scrollCtrl: _scrollCtrl,
currentUserId: room?.client.userID ?? '',
onReply: (event) => setState(() => _replyTo = event),
),
),
),
if (room != null)
ChatInput(
room: room,
replyTo: _replyTo,
onClearReply: () => setState(() => _replyTo = null),
),
],
),
);
}
}
class _MessageList extends StatelessWidget {
final Timeline timeline;
final ScrollController scrollCtrl;
final String currentUserId;
final ValueChanged<Event> onReply;
const _MessageList({
required this.timeline,
required this.scrollCtrl,
required this.currentUserId,
required this.onReply,
});
@override
Widget build(BuildContext context) {
final events = timeline.events
.where((e) =>
e.type == EventTypes.Message &&
e.status != EventStatus.error)
.toList();
if (events.isEmpty) {
return const Center(child: Text('Noch keine Nachrichten'));
}
return ListView.builder(
controller: scrollCtrl,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: events.length,
itemBuilder: (context, i) {
final event = events[i];
final isOwn = event.senderId == currentUserId;
final showDate = i == events.length - 1 ||
!_sameDay(event.originServerTs, events[i + 1].originServerTs);
final replyEventId =
event.content.tryGetMap<String, dynamic>('m.relates_to')
?['m.in_reply_to']?['event_id'] as String?;
final replyEvent = replyEventId != null
? timeline.events.firstWhere(
(e) => e.eventId == replyEventId,
orElse: () => event,
)
: null;
return Column(
children: [
if (showDate)
DateSeparator(date: event.originServerTs),
GestureDetector(
onLongPress: () => onReply(event),
child: MessageBubble(
event: event,
isOwn: isOwn,
replyEvent:
replyEvent?.eventId != event.eventId ? replyEvent : null,
),
),
],
);
},
);
}
bool _sameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
}
+135 -10
View File
@@ -1,20 +1,145 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/e2ee_diagnostics.dart';
import 'package:pyramid/core/matrix_client.dart';
// AsyncNotifier so onUpdate can refresh state in-place without recreating the
// timeline (which would reset the loaded event window back to the default 30).
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
Timeline? _timeline;
StreamSubscription<String>? _roomUpdateSub;
Timer? _redecryptTimer;
@override
Future<Timeline> build(String arg) async {
final roomId = arg;
final client = await ref.watch(matrixClientProvider.future);
final room = client.getRoomById(roomId);
if (room == null) throw Exception('Raum nicht gefunden: $roomId');
_timeline?.cancelSubscriptions();
_roomUpdateSub?.cancel();
_redecryptTimer?.cancel();
final diag = ref.read(e2eeDiagnosticsProvider.notifier);
final timeline = await room.getTimeline(
onUpdate: _onUpdate,
);
_timeline = timeline;
if (timeline.events.length < 20) {
await timeline.requestHistory(historyCount: 60);
}
timeline.requestKeys(onlineKeyBackupOnly: false);
unawaited(_decryptLegacyEvents(client, timeline, roomId, diag));
// Re-decrypt whenever the room updates (e.g. after new keys arrive from
// key requests). Debounced to avoid redundant passes on rapid updates.
_roomUpdateSub = room.onUpdate.stream.listen((_) {
_redecryptTimer?.cancel();
_redecryptTimer = Timer(const Duration(milliseconds: 800), () {
final t = _timeline;
if (t != null) {
unawaited(_decryptLegacyEvents(client, t, roomId, diag));
}
});
});
// Ensure device keys are downloaded for all room members so outbound
// Megolm session key sharing works on the first send.
if (room.encrypted && client.encryptionEnabled) {
unawaited(_preloadDeviceKeys(client, room));
}
ref.onDispose(() {
_roomUpdateSub?.cancel();
_redecryptTimer?.cancel();
_timeline?.cancelSubscriptions();
_timeline = null;
});
return timeline;
}
void _onUpdate() {
final t = _timeline;
if (t != null) state = AsyncData(t);
}
}
final timelineProvider =
FutureProvider.family<Timeline, String>((ref, roomId) async {
final client = await ref.watch(matrixClientProvider.future);
final room = client.getRoomById(roomId);
if (room == null) throw Exception('Raum nicht gefunden: $roomId');
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
TimelineNotifier.new,
);
final timeline = await room.getTimeline(
onUpdate: () => ref.invalidateSelf(),
);
Future<void> _decryptLegacyEvents(
Client client,
Timeline timeline,
String roomId,
E2eeDiagnosticsNotifier diag,
) async {
final enc = client.encryption;
if (enc == null) return;
ref.onDispose(timeline.cancelSubscriptions);
return timeline;
});
try {
await enc.keyManager.loadAllKeysFromRoom(roomId);
} catch (e) {
Logs().e('[ChatProvider] loadAllKeysFromRoom failed', e);
}
if (!enc.enabled) return;
var changed = false;
for (var i = 0; i < timeline.events.length; i++) {
final event = timeline.events[i];
if (event.type != EventTypes.Encrypted) continue;
try {
final decrypted = await enc.decryptRoomEvent(
event,
store: true,
updateType: EventUpdateType.history,
);
if (decrypted.type != EventTypes.Encrypted) {
timeline.events[i] = decrypted;
changed = true;
} else {
// Still encrypted — log for diagnostics
final sessionId = event.content.tryGet<String>('session_id') ?? '';
diag.add(E2eeDiagEntry(
timestamp: DateTime.now(),
roomId: roomId,
eventId: event.eventId,
sessionId: sessionId,
error: decrypted.content.tryGet<String>('body') ?? 'unknown',
));
}
} catch (e) {
final sessionId = event.content.tryGet<String>('session_id') ?? '';
diag.add(E2eeDiagEntry(
timestamp: DateTime.now(),
roomId: roomId,
eventId: event.eventId,
sessionId: sessionId,
error: e.toString(),
));
}
}
// Notify UI of in-place changes only when something actually decrypted
if (changed) timeline.onUpdate?.call();
}
Future<void> _preloadDeviceKeys(Client client, Room room) async {
try {
final members = await room.requestParticipants([Membership.join, Membership.invite]);
final userIds = members.map((m) => m.id).toSet();
await client.updateUserDeviceKeys(additionalUsers: userIds);
} catch (e) {
Logs().w('[ChatProvider] Device key preload failed: $e');
}
}
final roomProvider = Provider.family<Room?, String>((ref, roomId) {
final client = ref.watch(matrixClientProvider).valueOrNull;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+710
View File
@@ -0,0 +1,710 @@
import 'package:flutter/material.dart';
import 'package:pyramid/core/theme.dart';
class EmojiPicker extends StatefulWidget {
final ValueChanged<String> onEmojiSelected;
final VoidCallback onClose;
const EmojiPicker({
super.key,
required this.onEmojiSelected,
required this.onClose,
});
@override
State<EmojiPicker> createState() => _EmojiPickerState();
}
class _EmojiPickerState extends State<EmojiPicker> {
int _categoryIndex = 0;
final _searchCtrl = TextEditingController();
String _query = '';
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final filtered = _query.isEmpty
? _kCategories[_categoryIndex].emojis
: _kCategories
.expand((c) => c.emojis)
.where((e) => e.keywords.any((k) => k.contains(_query.toLowerCase())))
.toList();
return Container(
width: 340,
height: 380,
decoration: BoxDecoration(
color: pt.bg1,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: pt.border),
boxShadow: const [
BoxShadow(color: Color(0x50000000), blurRadius: 24, offset: Offset(0, 8)),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: TextField(
controller: _searchCtrl,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
hintText: 'Emoji suchen…',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
prefixIcon: Icon(Icons.search, size: 16, color: pt.fgDim),
isDense: true,
suffixIcon: _query.isNotEmpty
? IconButton(
icon: Icon(Icons.clear, size: 14, color: pt.fgDim),
onPressed: () {
_searchCtrl.clear();
setState(() => _query = '');
},
)
: null,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
filled: true,
fillColor: pt.bg2,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.accent),
),
),
onChanged: (v) => setState(() => _query = v.trim()),
),
),
// Category tabs (hidden during search)
if (_query.isEmpty)
SizedBox(
height: 36,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 6),
itemCount: _kCategories.length,
itemBuilder: (ctx, i) {
final selected = i == _categoryIndex;
return GestureDetector(
onTap: () => setState(() => _categoryIndex = i),
child: Container(
width: 36,
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
decoration: BoxDecoration(
color: selected ? pt.accentSoft : Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: selected ? pt.accent : Colors.transparent,
),
),
child: Center(
child: Text(
_kCategories[i].icon,
style: const TextStyle(fontSize: 16),
),
),
),
);
},
),
),
Container(height: 1, color: pt.border),
// Emoji grid
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(4),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
childAspectRatio: 1,
),
itemCount: filtered.length,
itemBuilder: (ctx, i) {
final emoji = filtered[i];
return _EmojiCell(
emoji: emoji.char,
tooltip: emoji.keywords.isNotEmpty ? emoji.keywords.first : '',
pt: pt,
onTap: () => widget.onEmojiSelected(emoji.char),
);
},
),
),
],
),
),
);
}
}
class _EmojiCell extends StatefulWidget {
final String emoji;
final String tooltip;
final PyramidTheme pt;
final VoidCallback onTap;
const _EmojiCell({
required this.emoji,
required this.tooltip,
required this.pt,
required this.onTap,
});
@override
State<_EmojiCell> createState() => _EmojiCellState();
}
class _EmojiCellState extends State<_EmojiCell> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Tooltip(
message: widget.tooltip,
waitDuration: const Duration(milliseconds: 600),
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
decoration: BoxDecoration(
color: _hovered ? widget.pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(widget.emoji, style: const TextStyle(fontSize: 20)),
),
),
),
),
);
}
}
// ─── Emoji data ────────────────────────────────────────────────────────────
class _EmojiEntry {
final String char;
final List<String> keywords;
const _EmojiEntry(this.char, this.keywords);
}
class _EmojiCategory {
final String icon;
final String name;
final List<_EmojiEntry> emojis;
const _EmojiCategory(this.icon, this.name, this.emojis);
}
const _kCategories = [
_EmojiCategory('😀', 'Smileys', _kSmileys),
_EmojiCategory('👋', 'Personen', _kPeople),
_EmojiCategory('🐶', 'Natur', _kNature),
_EmojiCategory('🍕', 'Essen', _kFood),
_EmojiCategory('', 'Aktivitäten', _kActivities),
_EmojiCategory('🚗', 'Reise', _kTravel),
_EmojiCategory('💡', 'Objekte', _kObjects),
_EmojiCategory('❤️', 'Symbole', _kSymbols),
];
const _kSmileys = [
_EmojiEntry('😀', ['lächeln', 'grinsen', 'happy', 'smiley']),
_EmojiEntry('😁', ['breit', 'grinsen']),
_EmojiEntry('😂', ['lachen', 'tränen', 'witzig', 'lol']),
_EmojiEntry('🤣', ['rollen', 'lachen', 'rofl']),
_EmojiEntry('😃', ['lächeln', 'glücklich']),
_EmojiEntry('😄', ['lächeln', 'augen']),
_EmojiEntry('😅', ['schwitzen', 'erleichtert']),
_EmojiEntry('😆', ['lachen', 'augen']),
_EmojiEntry('😉', ['zwinkern', 'wink']),
_EmojiEntry('😊', ['schüchtern', 'lächeln', 'rot']),
_EmojiEntry('😋', ['lecker', 'zunge']),
_EmojiEntry('😎', ['cool', 'sonnenbrille']),
_EmojiEntry('😍', ['verliebt', 'herz', 'augen']),
_EmojiEntry('🥰', ['verliebt', 'herzen']),
_EmojiEntry('😘', ['kuss', 'herz']),
_EmojiEntry('😗', ['kuss']),
_EmojiEntry('😚', ['kuss', 'augen']),
_EmojiEntry('😙', ['kuss', 'lächeln']),
_EmojiEntry('🥲', ['lächeln', 'tränen']),
_EmojiEntry('😏', ['verschmitzt', 'smirk']),
_EmojiEntry('😒', ['unzufrieden', 'unamused']),
_EmojiEntry('😞', ['enttäuscht']),
_EmojiEntry('😔', ['nachdenklich', 'traurig']),
_EmojiEntry('😟', ['besorgt']),
_EmojiEntry('😕', ['verwirrt', 'confused']),
_EmojiEntry('🙁', ['leicht', 'traurig']),
_EmojiEntry('☹️', ['traurig', 'frowning']),
_EmojiEntry('😣', ['kämpfend']),
_EmojiEntry('😖', ['verwirrt', 'confounded']),
_EmojiEntry('😫', ['erschöpft', 'tired']),
_EmojiEntry('😩', ['müde', 'weary']),
_EmojiEntry('🥺', ['bitte', 'augen']),
_EmojiEntry('😢', ['weinen', 'cry']),
_EmojiEntry('😭', ['laut', 'weinen', 'sob']),
_EmojiEntry('😤', ['wütend', 'frustriert']),
_EmojiEntry('😠', ['wütend', 'angry']),
_EmojiEntry('😡', ['sehr wütend', 'rage']),
_EmojiEntry('🤬', ['fluchen', 'wütend']),
_EmojiEntry('🤯', ['explodierend', 'schockiert']),
_EmojiEntry('😳', ['errötend', 'flushed']),
_EmojiEntry('🥵', ['heiß', 'hot']),
_EmojiEntry('🥶', ['kalt', 'cold']),
_EmojiEntry('😱', ['schreien', 'angst']),
_EmojiEntry('😨', ['ängstlich', 'fearful']),
_EmojiEntry('😰', ['schwitzen', 'angst']),
_EmojiEntry('😥', ['enttäuscht', 'erleichtert']),
_EmojiEntry('😓', ['schwitzen', 'niedergeschlagen']),
_EmojiEntry('🤗', ['umarmen', 'hug']),
_EmojiEntry('🤔', ['denken', 'thinking']),
_EmojiEntry('🫡', ['salutieren']),
_EmojiEntry('🤭', ['kichern', 'hand']),
_EmojiEntry('🫢', ['schockiert']),
_EmojiEntry('🤫', ['shush', 'flüstern']),
_EmojiEntry('🤥', ['lügen', 'pinocchio']),
_EmojiEntry('😶', ['kein mund', 'sprachlos']),
_EmojiEntry('😐', ['neutral']),
_EmojiEntry('😑', ['ausdruckslos']),
_EmojiEntry('😬', ['zähne', 'grimasse']),
_EmojiEntry('🙄', ['augen', 'genervt']),
_EmojiEntry('😯', ['überrascht', 'staunen']),
_EmojiEntry('😦', ['grimasse', 'frowning']),
_EmojiEntry('😧', ['gequält']),
_EmojiEntry('😮', ['überrascht', 'open mouth']),
_EmojiEntry('😲', ['erstaunt', 'astonished']),
_EmojiEntry('🥱', ['gähnen', 'müde']),
_EmojiEntry('🤤', ['sabbern', 'drooling']),
_EmojiEntry('😴', ['schlafen', 'zzz']),
_EmojiEntry('🤢', ['krank', 'übel']),
_EmojiEntry('🤮', ['erbrechen', 'krank']),
_EmojiEntry('🤧', ['niesen', 'krank']),
_EmojiEntry('😷', ['maske', 'krank']),
_EmojiEntry('🤒', ['fieber', 'krank']),
_EmojiEntry('🤕', ['verletzt', 'verband']),
_EmojiEntry('🤑', ['geld', 'reich']),
_EmojiEntry('🤠', ['cowboy', 'hut']),
_EmojiEntry('🥳', ['feiern', 'party']),
_EmojiEntry('🥸', ['verkleidet']),
_EmojiEntry('😎', ['cool', 'sonnenbrille']),
_EmojiEntry('🤓', ['nerd', 'brille']),
_EmojiEntry('🧐', ['monokel', 'nachdenklich']),
_EmojiEntry('😈', ['teufel', 'böse']),
_EmojiEntry('👿', ['teufel', 'wütend']),
_EmojiEntry('💀', ['tot', 'schädel']),
_EmojiEntry('☠️', ['totenkopf', 'kreuzknochgen']),
_EmojiEntry('👻', ['geist', 'halloween']),
_EmojiEntry('💩', ['häufchen', 'poop']),
_EmojiEntry('🤡', ['clown']),
_EmojiEntry('👾', ['monster', 'spiel']),
_EmojiEntry('🎃', ['halloween', 'kürbis']),
];
const _kPeople = [
_EmojiEntry('👋', ['hallo', 'winken', 'wave']),
_EmojiEntry('🤚', ['hand', 'stopp']),
_EmojiEntry('', ['hand', 'hoch']),
_EmojiEntry('🖐️', ['hand', 'finger']),
_EmojiEntry('👌', ['ok', 'prima']),
_EmojiEntry('🤌', ['finger', 'pinch']),
_EmojiEntry('✌️', ['frieden', 'victory', 'peace']),
_EmojiEntry('🤞', ['finger', 'daumen']),
_EmojiEntry('🤟', ['liebe', 'rock']),
_EmojiEntry('🤘', ['rock', 'metal']),
_EmojiEntry('👈', ['links', 'zeigen']),
_EmojiEntry('👉', ['rechts', 'zeigen']),
_EmojiEntry('👆', ['hoch', 'zeigen']),
_EmojiEntry('👇', ['runter', 'zeigen']),
_EmojiEntry('☝️', ['eins', 'oben']),
_EmojiEntry('👍', ['daumen', 'hoch', 'gut', 'like']),
_EmojiEntry('👎', ['daumen', 'runter', 'schlecht', 'dislike']),
_EmojiEntry('', ['faust', 'punch']),
_EmojiEntry('👊', ['faust', 'schlag']),
_EmojiEntry('🤛', ['faust', 'links']),
_EmojiEntry('🤜', ['faust', 'rechts']),
_EmojiEntry('👏', ['klatschen', 'applaus']),
_EmojiEntry('🙌', ['feiern', 'hände']),
_EmojiEntry('🤝', ['handschlag', 'vereinbarung']),
_EmojiEntry('🙏', ['bitte', 'danke', 'beten']),
_EmojiEntry('✍️', ['schreiben', 'stift']),
_EmojiEntry('💪', ['muskel', 'stark']),
_EmojiEntry('🦵', ['bein']),
_EmojiEntry('🦶', ['fuß']),
_EmojiEntry('👂', ['ohr', 'hören']),
_EmojiEntry('👃', ['nase', 'riechen']),
_EmojiEntry('🧠', ['gehirn', 'denken']),
_EmojiEntry('🦷', ['zahn']),
_EmojiEntry('👀', ['augen', 'schauen']),
_EmojiEntry('👁️', ['auge']),
_EmojiEntry('👅', ['zunge']),
_EmojiEntry('👄', ['lippen', 'kuss']),
_EmojiEntry('💋', ['kuss', 'lippen']),
_EmojiEntry('👶', ['baby']),
_EmojiEntry('🧒', ['kind']),
_EmojiEntry('👦', ['junge']),
_EmojiEntry('👧', ['mädchen']),
_EmojiEntry('🧑', ['person']),
_EmojiEntry('👱', ['blond']),
_EmojiEntry('👩', ['frau']),
_EmojiEntry('👨', ['mann']),
_EmojiEntry('🧓', ['älter']),
_EmojiEntry('👴', ['alter mann']),
_EmojiEntry('👵', ['alte frau']),
_EmojiEntry('🧑‍💻', ['programmierer', 'developer']),
_EmojiEntry('👨‍💻', ['mann', 'programmierer']),
_EmojiEntry('👩‍💻', ['frau', 'programmierin']),
];
const _kNature = [
_EmojiEntry('🐶', ['hund', 'dog']),
_EmojiEntry('🐱', ['katze', 'cat']),
_EmojiEntry('🐭', ['maus', 'mouse']),
_EmojiEntry('🐹', ['hamster']),
_EmojiEntry('🐰', ['hase', 'rabbit']),
_EmojiEntry('🦊', ['fuchs', 'fox']),
_EmojiEntry('🐻', ['bär', 'bear']),
_EmojiEntry('🐼', ['panda']),
_EmojiEntry('🐨', ['koala']),
_EmojiEntry('🐯', ['tiger']),
_EmojiEntry('🦁', ['löwe', 'lion']),
_EmojiEntry('🐮', ['kuh', 'cow']),
_EmojiEntry('🐷', ['schwein', 'pig']),
_EmojiEntry('🐸', ['frosch', 'frog']),
_EmojiEntry('🐵', ['affe', 'monkey']),
_EmojiEntry('🐔', ['huhn', 'chicken']),
_EmojiEntry('🐧', ['pinguin', 'penguin']),
_EmojiEntry('🐦', ['vogel', 'bird']),
_EmojiEntry('🦆', ['ente', 'duck']),
_EmojiEntry('🦅', ['adler', 'eagle']),
_EmojiEntry('🦉', ['eule', 'owl']),
_EmojiEntry('🦇', ['fledermaus', 'bat']),
_EmojiEntry('🐺', ['wolf']),
_EmojiEntry('🐗', ['wildschwein', 'boar']),
_EmojiEntry('🐴', ['pferd', 'horse']),
_EmojiEntry('🦄', ['einhorn', 'unicorn']),
_EmojiEntry('🐝', ['biene', 'bee']),
_EmojiEntry('🐛', ['raupe', 'caterpillar']),
_EmojiEntry('🦋', ['schmetterling', 'butterfly']),
_EmojiEntry('🐌', ['schnecke', 'snail']),
_EmojiEntry('🐞', ['marienkäfer', 'ladybug']),
_EmojiEntry('🐜', ['ameise', 'ant']),
_EmojiEntry('🌸', ['kirschblüte', 'cherry blossom']),
_EmojiEntry('🌺', ['hibiskus']),
_EmojiEntry('🌻', ['sonnenblume', 'sunflower']),
_EmojiEntry('🌹', ['rose']),
_EmojiEntry('🌷', ['tulpe', 'tulip']),
_EmojiEntry('🌼', ['blume', 'flower']),
_EmojiEntry('🌿', ['pflanze', 'plant']),
_EmojiEntry('☘️', ['kleeblatt', 'shamrock']),
_EmojiEntry('🍀', ['vierblättriges', 'glück', 'luck']),
_EmojiEntry('🌲', ['baum', 'tree']),
_EmojiEntry('🌳', ['baum', 'deciduous']),
_EmojiEntry('🌴', ['palme', 'palm']),
_EmojiEntry('🌵', ['kaktus', 'cactus']),
_EmojiEntry('🌾', ['gras', 'getreide']),
_EmojiEntry('🍄', ['pilz', 'mushroom']),
_EmojiEntry('🌊', ['welle', 'wave', 'ozean']),
_EmojiEntry('', ['wolken', 'sonne']),
_EmojiEntry('🌈', ['regenbogen', 'rainbow']),
_EmojiEntry('❄️', ['schnee', 'schneflocke', 'cold']),
_EmojiEntry('', ['stern', 'star']),
_EmojiEntry('🌟', ['stern', 'glitzern']),
_EmojiEntry('', ['funken', 'glitzern', 'sparkle']),
_EmojiEntry('🔥', ['feuer', 'fire', 'heiß']),
_EmojiEntry('🌙', ['mond', 'moon']),
_EmojiEntry('☀️', ['sonne', 'sun']),
];
const _kFood = [
_EmojiEntry('🍎', ['apfel', 'apple']),
_EmojiEntry('🍊', ['orange', 'mandarine']),
_EmojiEntry('🍋', ['zitrone', 'lemon']),
_EmojiEntry('🍇', ['trauben', 'grapes']),
_EmojiEntry('🍓', ['erdbeere', 'strawberry']),
_EmojiEntry('🍒', ['kirsche', 'cherry']),
_EmojiEntry('🍑', ['pfirsich', 'peach']),
_EmojiEntry('🥭', ['mango']),
_EmojiEntry('🍍', ['ananas', 'pineapple']),
_EmojiEntry('🥥', ['kokosnuss', 'coconut']),
_EmojiEntry('🍅', ['tomate', 'tomato']),
_EmojiEntry('🫐', ['blaubeere', 'blueberry']),
_EmojiEntry('🍆', ['aubergine', 'eggplant']),
_EmojiEntry('🥑', ['avocado']),
_EmojiEntry('🫑', ['paprika']),
_EmojiEntry('🌽', ['mais', 'corn']),
_EmojiEntry('🥕', ['karotte', 'carrot']),
_EmojiEntry('🧄', ['knoblauch', 'garlic']),
_EmojiEntry('🧅', ['zwiebel', 'onion']),
_EmojiEntry('🥔', ['kartoffel', 'potato']),
_EmojiEntry('🍞', ['brot', 'bread']),
_EmojiEntry('🥐', ['croissant']),
_EmojiEntry('🧀', ['käse', 'cheese']),
_EmojiEntry('🥚', ['ei', 'egg']),
_EmojiEntry('🍳', ['bratpfanne', 'kochen']),
_EmojiEntry('🥓', ['speck', 'bacon']),
_EmojiEntry('🍗', ['hühnchen', 'chicken']),
_EmojiEntry('🍖', ['fleisch', 'knochen']),
_EmojiEntry('🌭', ['hotdog']),
_EmojiEntry('🍔', ['burger', 'hamburger']),
_EmojiEntry('🍟', ['pommes', 'fries']),
_EmojiEntry('🍕', ['pizza']),
_EmojiEntry('🥪', ['sandwich']),
_EmojiEntry('🌮', ['taco']),
_EmojiEntry('🌯', ['wrap']),
_EmojiEntry('🥗', ['salat', 'salad']),
_EmojiEntry('🍜', ['nudeln', 'noodles', 'ramen']),
_EmojiEntry('🍣', ['sushi']),
_EmojiEntry('🍦', ['eis', 'softeis']),
_EmojiEntry('🎂', ['torte', 'geburtstag']),
_EmojiEntry('🍰', ['kuchen', 'cake']),
_EmojiEntry('🧁', ['muffin', 'cupcake']),
_EmojiEntry('🍩', ['donut']),
_EmojiEntry('🍪', ['keks', 'cookie']),
_EmojiEntry('🍫', ['schokolade', 'chocolate']),
_EmojiEntry('🍬', ['bonbon', 'candy']),
_EmojiEntry('', ['kaffee', 'coffee']),
_EmojiEntry('🍵', ['tee', 'tea']),
_EmojiEntry('🧋', ['bubble tea']),
_EmojiEntry('🥤', ['getränk', 'cup']),
_EmojiEntry('🍺', ['bier', 'beer']),
_EmojiEntry('🍻', ['bier', 'prost', 'cheers']),
_EmojiEntry('🥂', ['sekt', 'cheers', 'toast']),
_EmojiEntry('🍷', ['wein', 'wine']),
_EmojiEntry('🥃', ['whiskey']),
];
const _kActivities = [
_EmojiEntry('', ['fußball', 'soccer']),
_EmojiEntry('🏀', ['basketball']),
_EmojiEntry('🏈', ['american football']),
_EmojiEntry('', ['baseball']),
_EmojiEntry('🎾', ['tennis']),
_EmojiEntry('🏐', ['volleyball']),
_EmojiEntry('🏉', ['rugby']),
_EmojiEntry('🥏', ['frisbee']),
_EmojiEntry('🎱', ['billard', 'pool']),
_EmojiEntry('🏓', ['tischtennis', 'ping pong']),
_EmojiEntry('🏸', ['badminton']),
_EmojiEntry('🥊', ['boxen', 'boxing']),
_EmojiEntry('🥋', ['kampfsport', 'martial arts']),
_EmojiEntry('🥅', ['tor', 'goal']),
_EmojiEntry('', ['golf']),
_EmojiEntry('🎿', ['ski']),
_EmojiEntry('🛷', ['schlitten', 'sled']),
_EmojiEntry('🏆', ['pokal', 'trophy', 'gewonnen']),
_EmojiEntry('🥇', ['gold', 'erster']),
_EmojiEntry('🥈', ['silber', 'zweiter']),
_EmojiEntry('🥉', ['bronze', 'dritter']),
_EmojiEntry('🎮', ['spiel', 'gaming', 'controller']),
_EmojiEntry('🕹️', ['joystick', 'spiel']),
_EmojiEntry('🎲', ['würfel', 'dice']),
_EmojiEntry('🧩', ['puzzle']),
_EmojiEntry('🎯', ['ziel', 'dartscheibe']),
_EmojiEntry('🎳', ['bowling']),
_EmojiEntry('🎪', ['zirkus', 'circus']),
_EmojiEntry('🎨', ['kunst', 'malen', 'art']),
_EmojiEntry('🎭', ['theater', 'drama']),
_EmojiEntry('🎬', ['film', 'klappe', 'movie']),
_EmojiEntry('🎵', ['musik', 'note']),
_EmojiEntry('🎶', ['musik', 'noten']),
_EmojiEntry('🎸', ['gitarre', 'guitar']),
_EmojiEntry('🎹', ['klavier', 'piano']),
_EmojiEntry('🥁', ['schlagzeug', 'drum']),
_EmojiEntry('🎷', ['saxophon']),
_EmojiEntry('🎺', ['trompete', 'trumpet']),
_EmojiEntry('🎻', ['geige', 'violin']),
_EmojiEntry('🎤', ['mikrofon', 'microphone']),
];
const _kTravel = [
_EmojiEntry('🚗', ['auto', 'car']),
_EmojiEntry('🚕', ['taxi']),
_EmojiEntry('🚙', ['suv', 'auto']),
_EmojiEntry('🚌', ['bus']),
_EmojiEntry('🚎', ['trolleybus']),
_EmojiEntry('🏎️', ['rennauto', 'racecar']),
_EmojiEntry('🚓', ['polizei', 'police']),
_EmojiEntry('🚑', ['krankenwagen', 'ambulance']),
_EmojiEntry('🚒', ['feuerwehr', 'fire truck']),
_EmojiEntry('🚐', ['minibus', 'van']),
_EmojiEntry('🛻', ['pickup']),
_EmojiEntry('🚚', ['lieferwagen', 'truck']),
_EmojiEntry('🚛', ['lkw', 'truck']),
_EmojiEntry('🏍️', ['motorrad', 'motorcycle']),
_EmojiEntry('🚲', ['fahrrad', 'bicycle', 'bike']),
_EmojiEntry('🛵', ['roller', 'scooter']),
_EmojiEntry('✈️', ['flugzeug', 'plane', 'fliegen']),
_EmojiEntry('🚀', ['rakete', 'rocket', 'space']),
_EmojiEntry('🛸', ['ufo']),
_EmojiEntry('🚁', ['hubschrauber', 'helicopter']),
_EmojiEntry('', ['segelboot', 'sailboat']),
_EmojiEntry('🚢', ['schiff', 'ship']),
_EmojiEntry('🚂', ['zug', 'train']),
_EmojiEntry('🚄', ['hochgeschwindigkeitszug', 'bullet train']),
_EmojiEntry('🏠', ['haus', 'home']),
_EmojiEntry('🏡', ['haus', 'garten']),
_EmojiEntry('🏢', ['büro', 'office']),
_EmojiEntry('🏰', ['schloss', 'castle']),
_EmojiEntry('🗼', ['turm', 'eiffelturm']),
_EmojiEntry('🗽', ['freiheitsstatue']),
_EmojiEntry('🌍', ['erde', 'europa', 'africa']),
_EmojiEntry('🌎', ['erde', 'americas']),
_EmojiEntry('🌏', ['erde', 'asia']),
_EmojiEntry('🗺️', ['karte', 'map']),
_EmojiEntry('🧭', ['kompass', 'compass']),
_EmojiEntry('', ['zelt', 'camping']),
_EmojiEntry('🏖️', ['strand', 'beach']),
_EmojiEntry('🏔️', ['berg', 'mountain']),
_EmojiEntry('🌋', ['vulkan', 'volcano']),
];
const _kObjects = [
_EmojiEntry('💡', ['idee', 'licht', 'light', 'lamp']),
_EmojiEntry('🔦', ['taschenlampe', 'flashlight']),
_EmojiEntry('💻', ['laptop', 'computer']),
_EmojiEntry('🖥️', ['monitor', 'desktop']),
_EmojiEntry('🖨️', ['drucker', 'printer']),
_EmojiEntry('⌨️', ['tastatur', 'keyboard']),
_EmojiEntry('🖱️', ['maus', 'mouse']),
_EmojiEntry('📱', ['handy', 'phone', 'smartphone']),
_EmojiEntry('☎️', ['telefon', 'phone']),
_EmojiEntry('📞', ['telefon', 'anruf', 'call']),
_EmojiEntry('📷', ['kamera', 'camera']),
_EmojiEntry('📸', ['foto', 'kamera', 'selfie']),
_EmojiEntry('📹', ['video', 'kamera']),
_EmojiEntry('🎥', ['film', 'kamera', 'movie']),
_EmojiEntry('📺', ['tv', 'fernseher', 'television']),
_EmojiEntry('📻', ['radio']),
_EmojiEntry('🎙️', ['mikrofon', 'microphone']),
_EmojiEntry('📡', ['satellit', 'satellite']),
_EmojiEntry('🔋', ['batterie', 'battery']),
_EmojiEntry('🔌', ['stecker', 'plug']),
_EmojiEntry('💾', ['diskette', 'speichern', 'save']),
_EmojiEntry('💿', ['cd', 'disk']),
_EmojiEntry('📀', ['dvd', 'disc']),
_EmojiEntry('📁', ['ordner', 'folder']),
_EmojiEntry('📂', ['ordner', 'offen']),
_EmojiEntry('📄', ['datei', 'dokument', 'file']),
_EmojiEntry('📃', ['seite', 'dokument']),
_EmojiEntry('📋', ['zwischenablage', 'clipboard']),
_EmojiEntry('📊', ['grafik', 'diagramm', 'chart']),
_EmojiEntry('📈', ['aufwärts', 'wachstum', 'chart']),
_EmojiEntry('📉', ['abwärts', 'rückgang', 'chart']),
_EmojiEntry('📌', ['pin', 'nadel']),
_EmojiEntry('📍', ['ort', 'pin', 'location']),
_EmojiEntry('✏️', ['stift', 'pen']),
_EmojiEntry('✒️', ['füllfeder', 'pen']),
_EmojiEntry('🖊️', ['kugelschreiber', 'pen']),
_EmojiEntry('📝', ['notiz', 'memo', 'schreiben']),
_EmojiEntry('📚', ['bücher', 'books']),
_EmojiEntry('📖', ['buch', 'lesen']),
_EmojiEntry('🔑', ['schlüssel', 'key']),
_EmojiEntry('🔒', ['schloss', 'lock']),
_EmojiEntry('🔓', ['offen', 'unlock']),
_EmojiEntry('🔨', ['hammer']),
_EmojiEntry('🪛', ['schraubenzieher']),
_EmojiEntry('⚙️', ['zahnrad', 'einstellungen', 'settings']),
_EmojiEntry('🧲', ['magnet']),
_EmojiEntry('🔭', ['teleskop', 'telescope']),
_EmojiEntry('🔬', ['mikroskop', 'microscope']),
_EmojiEntry('💊', ['pille', 'medikament']),
_EmojiEntry('🩺', ['stethoskop', 'arzt']),
_EmojiEntry('🧪', ['reagenzglas', 'experiment']),
_EmojiEntry('💰', ['geld', 'tasche', 'money']),
_EmojiEntry('💳', ['karte', 'kreditkarte']),
_EmojiEntry('🎁', ['geschenk', 'present']),
_EmojiEntry('🎀', ['schleife', 'ribbon']),
_EmojiEntry('🧨', ['feuerwerk', 'cracker']),
_EmojiEntry('🎉', ['feier', 'party', 'celebration']),
_EmojiEntry('🎊', ['konfetti', 'party']),
_EmojiEntry('🏮', ['laterne', 'lantern']),
_EmojiEntry('', ['uhr', 'watch', 'zeit']),
_EmojiEntry('📅', ['kalender', 'calendar']),
_EmojiEntry('', ['wecker', 'alarm']),
_EmojiEntry('', ['sanduhr', 'hourglass']),
];
const _kSymbols = [
_EmojiEntry('❤️', ['herz', 'liebe', 'love', 'heart']),
_EmojiEntry('🧡', ['orange', 'herz']),
_EmojiEntry('💛', ['gelb', 'herz']),
_EmojiEntry('💚', ['grün', 'herz']),
_EmojiEntry('💙', ['blau', 'herz']),
_EmojiEntry('💜', ['lila', 'herz']),
_EmojiEntry('🖤', ['schwarz', 'herz']),
_EmojiEntry('🤍', ['weiß', 'herz']),
_EmojiEntry('🤎', ['braun', 'herz']),
_EmojiEntry('💔', ['gebrochenes herz', 'broken heart']),
_EmojiEntry('❣️', ['herz', 'ausrufezeichen']),
_EmojiEntry('💕', ['zwei herzen', 'love']),
_EmojiEntry('💞', ['herzen', 'drehend']),
_EmojiEntry('💓', ['herz', 'schlagen']),
_EmojiEntry('💗', ['herz', 'wachsend']),
_EmojiEntry('💖', ['herz', 'glitzer']),
_EmojiEntry('💝', ['herz', 'schleife']),
_EmojiEntry('💘', ['herz', 'pfeil', 'cupid']),
_EmojiEntry('💟', ['herz', 'dekoration']),
_EmojiEntry('☮️', ['frieden', 'peace']),
_EmojiEntry('✝️', ['kreuz', 'christian']),
_EmojiEntry('☯️', ['yin yang']),
_EmojiEntry('🔴', ['rot', 'kreis', 'red']),
_EmojiEntry('🟠', ['orange', 'kreis']),
_EmojiEntry('🟡', ['gelb', 'kreis']),
_EmojiEntry('🟢', ['grün', 'kreis']),
_EmojiEntry('🔵', ['blau', 'kreis']),
_EmojiEntry('🟣', ['lila', 'kreis']),
_EmojiEntry('', ['schwarz', 'kreis']),
_EmojiEntry('', ['weiß', 'kreis']),
_EmojiEntry('🟤', ['braun', 'kreis']),
_EmojiEntry('🔺', ['rot', 'dreieck']),
_EmojiEntry('🔻', ['rot', 'dreieck', 'runter']),
_EmojiEntry('♾️', ['unendlich', 'infinity']),
_EmojiEntry('', ['haken', 'check', 'ok', 'ja']),
_EmojiEntry('', ['x', 'nein', 'falsch', 'close']),
_EmojiEntry('', ['x', 'kreuz', 'nein']),
_EmojiEntry('', ['kreis', 'richtig']),
_EmojiEntry('🚫', ['verboten', 'nein']),
_EmojiEntry('⚠️', ['warnung', 'warning']),
_EmojiEntry('🔞', ['verboten', '18+']),
_EmojiEntry('', ['ausrufezeichen', 'wichtig']),
_EmojiEntry('', ['fragezeichen', 'frage']),
_EmojiEntry('💯', ['100', 'perfekt', 'perfect']),
_EmojiEntry('🆗', ['ok', 'schaltfläche']),
_EmojiEntry('🆕', ['neu', 'new']),
_EmojiEntry('🆙', ['up']),
_EmojiEntry('🔝', ['top', 'oben']),
_EmojiEntry('🔛', ['an']),
_EmojiEntry('🔜', ['bald', 'soon']),
_EmojiEntry('🔚', ['ende', 'end']),
_EmojiEntry('♻️', ['recycling', 'wiederverwertung']),
_EmojiEntry('💲', ['dollar', 'geld']),
_EmojiEntry('©️', ['copyright']),
_EmojiEntry('®️', ['eingetragen', 'trademark']),
_EmojiEntry('™️', ['markenzeichen', 'trademark']),
_EmojiEntry('🔔', ['glocke', 'bell', 'benachrichtigung']),
_EmojiEntry('🔕', ['stille', 'no bell']),
_EmojiEntry('🎵', ['musik', 'note']),
_EmojiEntry('🎶', ['musik', 'noten']),
_EmojiEntry('#️⃣', ['raute', 'hash', 'hashtag']),
_EmojiEntry('*️⃣', ['stern', 'asterisk']),
_EmojiEntry('0️⃣', ['null', 'zero']),
_EmojiEntry('1️⃣', ['eins', 'one']),
_EmojiEntry('2️⃣', ['zwei', 'two']),
_EmojiEntry('3️⃣', ['drei', 'three']),
];
+696
View File
@@ -0,0 +1,696 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/utils/gif_favorite_service.dart';
const _giphyApiKey = 'QtEyHWSKVIZJersKNBbJGYIgOhwawjkk';
const _stickerServerUrl = 'https://stickers.steggi-matrix.work';
class GifStickerPicker extends StatefulWidget {
final Room room;
final VoidCallback onClose;
const GifStickerPicker({
super.key,
required this.room,
required this.onClose,
});
@override
State<GifStickerPicker> createState() => _GifStickerPickerState();
}
class _GifStickerPickerState extends State<GifStickerPicker>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _searchController = TextEditingController();
final _customUrlController = TextEditingController();
final _gifScrollController = ScrollController();
final _packSelectorScroll = ScrollController();
List<dynamic> _gifs = [];
bool _gifLoading = false;
int _gifOffset = 0;
int _gifTotal = 0;
String _gifQuery = '';
int? _hoveredGifIndex;
int? _hoveredFavIndex;
static const _kFavsPack = '__favs__';
static const _kMyPack = '__my__';
List<String> _packs = [];
final Map<String, dynamic> _packData = {};
String? _selectedPack;
bool _stickerLoading = false;
bool _packLoading = false;
PyramidTheme? _pt;
List<String> get _allPackTabs {
final tabs = <String>[];
if (GifFavoriteService.stickerFavoritesItems.isNotEmpty) tabs.add(_kFavsPack);
if (GifFavoriteService.userStickerItems.isNotEmpty) tabs.add(_kMyPack);
tabs.addAll(_packs);
return tabs;
}
void _selectDefaultTab() {
final tabs = _allPackTabs;
if (tabs.isNotEmpty && (_selectedPack == null || !tabs.contains(_selectedPack))) {
setState(() => _selectedPack = tabs.first);
}
}
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_gifScrollController.addListener(_onGifScroll);
_loadTrendingGifs();
GifFavoriteService.load().then((_) => setState(() {}));
GifFavoriteService.loadStickers().then((_) {
_selectDefaultTab();
setState(() {});
});
_loadStickerPacks();
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
_customUrlController.dispose();
_gifScrollController.dispose();
_packSelectorScroll.dispose();
super.dispose();
}
void _onGifScroll() {
if (_gifScrollController.position.pixels >=
_gifScrollController.position.maxScrollExtent - 300) {
if (!_gifLoading && _gifOffset < _gifTotal) _loadMoreGifs();
}
}
Future<void> _loadTrendingGifs() async {
setState(() { _gifLoading = true; _gifs = []; _gifOffset = 0; _gifQuery = ''; });
try {
final res = await http.get(Uri.parse(
'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=24&offset=0',
));
final data = jsonDecode(res.body);
setState(() {
_gifs = data['data'] ?? [];
_gifTotal = data['pagination']?['total_count'] ?? 0;
_gifOffset = _gifs.length;
_gifLoading = false;
});
} catch (_) {
setState(() => _gifLoading = false);
}
}
Future<void> _searchGifs(String query) async {
if (query.isEmpty) { _loadTrendingGifs(); return; }
setState(() { _gifLoading = true; _gifs = []; _gifOffset = 0; _gifQuery = query; });
try {
final res = await http.get(Uri.parse(
'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(query)}&limit=24&offset=0',
));
final data = jsonDecode(res.body);
setState(() {
_gifs = data['data'] ?? [];
_gifTotal = data['pagination']?['total_count'] ?? 0;
_gifOffset = _gifs.length;
_gifLoading = false;
});
} catch (_) {
setState(() => _gifLoading = false);
}
}
Future<void> _loadMoreGifs() async {
if (_gifLoading) return;
setState(() => _gifLoading = true);
try {
final url = _gifQuery.isEmpty
? 'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=24&offset=$_gifOffset'
: 'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(_gifQuery)}&limit=24&offset=$_gifOffset';
final res = await http.get(Uri.parse(url));
final data = jsonDecode(res.body);
setState(() {
_gifs.addAll(data['data'] ?? []);
_gifOffset = _gifs.length;
_gifLoading = false;
});
} catch (_) {
setState(() => _gifLoading = false);
}
}
Future<void> _toggleGifFav(String url, String previewUrl, String title) async {
await GifFavoriteService.toggle(url, previewUrl, title);
setState(() {});
}
Future<void> _loadStickerPacks() async {
setState(() => _stickerLoading = true);
try {
final userId = widget.room.client.userID ?? '';
final res = await http.get(Uri.parse(
'$_stickerServerUrl/packs/index.json?userId=${Uri.encodeComponent(userId)}',
));
final data = jsonDecode(res.body);
final packs = List<String>.from(data['packs'] ?? [])
.where((p) => !p.startsWith('__'))
.toList();
setState(() { _packs = packs; _stickerLoading = false; });
_selectDefaultTab();
if (_selectedPack != null && _selectedPack != _kFavsPack && _selectedPack != _kMyPack) {
await _loadPack(_selectedPack!);
}
} catch (_) {
setState(() => _stickerLoading = false);
}
}
Future<void> _loadPack(String packId) async {
if (_packData.containsKey(packId)) return;
setState(() => _packLoading = true);
try {
final res = await http.get(Uri.parse('$_stickerServerUrl/packs/$packId/pack.json'));
setState(() { _packData[packId] = jsonDecode(res.body); _packLoading = false; });
} catch (_) {
setState(() => _packLoading = false);
}
}
Future<void> _sendGif(String url, String title) async {
widget.onClose();
await widget.room.sendEvent({
'msgtype': 'm.image',
'body': title.isNotEmpty ? title : 'GIF',
'url': url,
'info': {'mimetype': 'image/gif'},
});
}
Future<void> _sendSticker(String mxcUrl, String body, Map<String, dynamic> info) async {
widget.onClose();
await widget.room.sendEvent(
{'body': body, 'url': mxcUrl, 'info': info},
type: EventTypes.Sticker,
);
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
_pt = pt;
return Container(
width: 380,
height: 460,
decoration: BoxDecoration(
color: pt.bg1,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: pt.border),
boxShadow: const [
BoxShadow(color: Color(0x50000000), blurRadius: 24, offset: Offset(0, 8))
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
children: [
// Header with tabs
Container(
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: pt.border)),
),
child: TabBar(
controller: _tabController,
indicatorColor: pt.accent,
labelColor: pt.accent,
unselectedLabelColor: pt.fgMuted,
tabs: const [
Tab(icon: Icon(Icons.gif_box_outlined, size: 20)),
Tab(icon: Icon(Icons.sticky_note_2_outlined, size: 20)),
],
),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [_buildGifTab(), _buildStickerTab()],
),
),
],
),
),
);
}
// ── GIF Tab ──────────────────────────────────────────────────────────────
Widget _buildGifTab() {
final favorites = GifFavoriteService.cache;
final isSearching = _gifQuery.isNotEmpty;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: TextField(
controller: _searchController,
style: TextStyle(color: _pt!.fg, fontSize: 13),
decoration: InputDecoration(
hintText: 'GIFs suchen…',
hintStyle: TextStyle(color: _pt!.fgDim, fontSize: 13),
prefixIcon: Icon(Icons.search, size: 16, color: _pt!.fgDim),
isDense: true,
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear, size: 16, color: _pt!.fgDim),
onPressed: () {
_searchController.clear();
setState(() {});
_loadTrendingGifs();
},
)
: null,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
filled: true,
fillColor: _pt!.bg2,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _pt!.border)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _pt!.border)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _pt!.accent)),
),
onChanged: (v) => setState(() {}),
onSubmitted: _searchGifs,
),
),
Expanded(
child: _gifLoading && _gifs.isEmpty
? Center(child: CircularProgressIndicator(color: _pt!.accent))
: CustomScrollView(
controller: _gifScrollController,
slivers: [
if (!isSearching && favorites.isNotEmpty) ...[
SliverToBoxAdapter(child: _SectionLabel('❤ Favoriten')),
_gifGrid(
count: favorites.length,
builder: (i) {
final fav = favorites[i];
final url = fav['url'] ?? '';
final preview = fav['preview_url'] ?? url;
final title = fav['title'] ?? 'GIF';
return _GifCell(
imageUrl: preview,
isFavorite: true,
isHovered: _hoveredFavIndex == i,
onHoverChanged: (h) => setState(() => _hoveredFavIndex = h ? i : null),
onTap: () => _sendGif(url, title),
onFavToggle: () => _toggleGifFav(url, preview, title),
);
},
),
SliverToBoxAdapter(child: _SectionLabel('Trending')),
],
_gifGrid(
count: _gifs.length,
builder: (i) {
final gif = _gifs[i];
final preview = gif['images']?['fixed_height_small']?['url'] ?? gif['images']?['fixed_height']?['url'] ?? '';
final full = gif['images']?['fixed_height']?['url'] ?? '';
final title = gif['title'] ?? 'GIF';
final fav = GifFavoriteService.isFavorite(full);
return _GifCell(
imageUrl: preview,
isFavorite: fav,
isHovered: _hoveredGifIndex == i,
onHoverChanged: (h) => setState(() => _hoveredGifIndex = h ? i : null),
onTap: () => _sendGif(full, title),
onFavToggle: () => _toggleGifFav(full, preview, title),
);
},
),
if (_gifLoading)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(12),
child: Center(child: CircularProgressIndicator(
strokeWidth: 2, color: _pt!.accent)),
),
),
],
),
),
],
);
}
SliverGrid _gifGrid({required int count, required Widget Function(int) builder}) =>
SliverGrid(
delegate: SliverChildBuilderDelegate((ctx, i) => builder(i), childCount: count),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisSpacing: 2, mainAxisSpacing: 2, childAspectRatio: 1,
),
);
// ── Sticker Tab ──────────────────────────────────────────────────────────
Widget _buildStickerTab() {
final allTabs = _allPackTabs;
if (_stickerLoading) {
return Center(child: CircularProgressIndicator(color: _pt!.accent));
}
if (allTabs.isEmpty) {
return Center(child: Text('Keine Sticker verfügbar', style: TextStyle(color: _pt!.fgMuted)));
}
final selected = (allTabs.contains(_selectedPack) ? _selectedPack : allTabs.first)!;
return Column(
children: [
SizedBox(
height: 38,
child: ListView.builder(
controller: _packSelectorScroll,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
itemCount: allTabs.length,
itemBuilder: (context, index) {
final tab = allTabs[index];
final isSelected = tab == selected;
String label = tab == _kFavsPack ? '⭐ Favoriten' : tab == _kMyPack ? '🎨 Meine' : (_packData[tab]?['title'] as String? ?? tab);
return Padding(
padding: const EdgeInsets.only(right: 6),
child: GestureDetector(
onTap: () async {
setState(() => _selectedPack = tab);
if (tab != _kFavsPack && tab != _kMyPack) await _loadPack(tab);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
color: isSelected ? _pt!.accentSoft : Colors.transparent,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected ? _pt!.accent : _pt!.border,
),
),
child: Text(label,
style: TextStyle(
fontSize: 11,
color: isSelected ? _pt!.accent : _pt!.fgMuted,
)),
),
),
);
},
),
),
Container(height: 1, color: _pt!.border),
Expanded(
child: selected == _kFavsPack
? _buildStickerGrid(GifFavoriteService.stickerFavoritesItems)
: selected == _kMyPack
? _buildStickerGrid(GifFavoriteService.userStickerItems, isMyStickers: true)
: _buildPackGrid(selected),
),
],
);
}
Widget _buildStickerGrid(List<Map<String, dynamic>> items, {bool isMyStickers = false}) {
if (items.isEmpty) {
return Center(child: Text('Keine Sticker', style: TextStyle(color: _pt!.fgMuted)));
}
return GridView.builder(
padding: const EdgeInsets.all(4),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4, crossAxisSpacing: 3, mainAxisSpacing: 3,
),
itemCount: items.length,
itemBuilder: (ctx, i) {
final s = items[i];
final piUrl = s['url'] as String? ?? '';
final sendMxc = (s['mxc'] as String?)?.isNotEmpty == true
? s['mxc'] as String
: (s['mxc_source'] as String? ?? '');
final mxcSource = s['mxc_source'] as String? ?? '';
final title = s['title'] as String? ?? 'Sticker';
final info = {'mimetype': s['mimetype'] ?? 'image/webp'};
final isUserSticker = s['user_sticker'] == true;
return _StickerCell(
piUrl: piUrl,
isFavorited: s['favorited'] == true,
showHeart: isMyStickers && isUserSticker,
onTap: () => _sendSticker(sendMxc, title, info),
onToggleFavorite: (isMyStickers && isUserSticker)
? () async {
final newFav = !(s['favorited'] == true);
await GifFavoriteService.setUserStickerFavorited(mxcSource, newFav);
if (mounted) setState(() {});
}
: null,
);
},
);
}
Widget _buildPackGrid(String packId) {
if (_packLoading) {
return Center(child: CircularProgressIndicator(color: _pt!.accent));
}
final stickers = (_packData[packId]?['stickers'] as List<dynamic>?) ?? [];
if (stickers.isEmpty) {
return Center(child: Text('Leer', style: TextStyle(color: _pt!.fgMuted)));
}
return GridView.builder(
padding: const EdgeInsets.all(4),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4, crossAxisSpacing: 3, mainAxisSpacing: 3,
),
itemCount: stickers.length,
itemBuilder: (ctx, i) {
final s = stickers[i];
final id = s['id']?.toString() ?? '';
final thumbUrl = '$_stickerServerUrl/packs/$packId/${id}_thumb.png';
final mxcUrl = (s['url'] as String?) ?? '';
final body = (s['body'] as String?) ?? '';
final mimetype = (s['info']?['mimetype'] as String?) ?? 'image/webp';
final info = Map<String, dynamic>.from(s['info'] as Map? ?? {});
final isFav = GifFavoriteService.isStickerFavorite(mxcUrl);
return GestureDetector(
onTap: () => _sendSticker(mxcUrl, body, info),
onLongPressStart: (details) {
final dx = details.globalPosition.dx;
final dy = details.globalPosition.dy;
final screen = MediaQuery.sizeOf(context);
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
dx, dy, screen.width - dx, screen.height - dy,
),
items: [
PopupMenuItem(
value: 'fav',
child: Row(children: [
Icon(
isFav ? Icons.favorite_border : Icons.favorite,
size: 16,
color: isFav ? _pt!.fgMuted : Colors.red,
),
const SizedBox(width: 8),
Text(
isFav ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen',
style: TextStyle(color: _pt!.fg, fontSize: 14),
),
]),
),
],
).then((val) async {
if (val == 'fav') {
await GifFavoriteService.togglePackStickerFavorite(
mxcUrl: mxcUrl,
thumbUrl: thumbUrl,
title: body,
mimetype: mimetype,
);
if (mounted) setState(() {});
}
});
},
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
thumbUrl,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Container(color: _pt!.bg2),
),
),
);
},
);
}
}
class _StickerCell extends StatefulWidget {
final String piUrl;
final bool isFavorited;
final bool showHeart;
final VoidCallback onTap;
final Future<void> Function()? onToggleFavorite;
const _StickerCell({
required this.piUrl,
required this.isFavorited,
required this.showHeart,
required this.onTap,
this.onToggleFavorite,
});
@override
State<_StickerCell> createState() => _StickerCellState();
}
class _StickerCellState extends State<_StickerCell> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final showOverlay = widget.showHeart && (_hovered || widget.isFavorited);
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
widget.piUrl,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Container(color: pt.bg2),
),
if (showOverlay)
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: widget.onToggleFavorite,
behavior: HitTestBehavior.opaque,
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: Icon(
widget.isFavorited ? Icons.favorite : Icons.favorite_border,
color: widget.isFavorited ? Colors.red : Colors.white,
size: 13,
),
),
),
),
],
),
),
),
);
}
}
class _SectionLabel extends StatelessWidget {
final String title;
const _SectionLabel(this.title);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(title,
style: TextStyle(color: PyramidColors.fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6)),
);
}
}
class _GifCell extends StatelessWidget {
final String imageUrl;
final bool isFavorite;
final bool isHovered;
final ValueChanged<bool> onHoverChanged;
final VoidCallback onTap;
final VoidCallback onFavToggle;
const _GifCell({
required this.imageUrl,
required this.isFavorite,
required this.isHovered,
required this.onHoverChanged,
required this.onTap,
required this.onFavToggle,
});
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => onHoverChanged(true),
onExit: (_) => onHoverChanged(false),
child: GestureDetector(
onTap: onTap,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
fit: StackFit.expand,
children: [
Image.network(imageUrl, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: PyramidTheme.of(context).bg3)),
if (isHovered || isFavorite)
Positioned(
top: 4, right: 4,
child: GestureDetector(
onTap: onFavToggle,
behavior: HitTestBehavior.opaque,
child: Container(
width: 22, height: 22,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? Colors.red : Colors.white,
size: 13,
),
),
),
),
],
),
),
),
);
}
}
+904
View File
@@ -0,0 +1,904 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/media_viewer.dart';
// ─── Audio Player ─────────────────────────────────────────────────────────────
//
// isVoice=true → compact row (Sprachnachricht in Chat)
// isVoice=false → defaultBars card mit Waveform (Audiodatei)
class PyramidAudioPlayer extends StatefulWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
final bool isVoice;
const PyramidAudioPlayer({
super.key,
required this.bytes,
required this.filename,
required this.pt,
this.isVoice = false,
});
@override
State<PyramidAudioPlayer> createState() => _PyramidAudioPlayerState();
}
class _PyramidAudioPlayerState extends State<PyramidAudioPlayer> {
late final Player _player;
bool _loading = true;
double _speed = 1.0;
static const _speeds = [1.0, 1.5, 2.0];
@override
void initState() {
super.initState();
_player = Player();
_init();
}
Future<void> _init() async {
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'ogg';
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/pyr_audio_${widget.bytes.hashCode}.$ext');
await file.writeAsBytes(widget.bytes);
await _player.open(Media('file://${file.path}'), play: false);
if (mounted) setState(() => _loading = false);
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
String _fmt(Duration d) {
final m = d.inMinutes.remainder(60);
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
void _seek(Duration to) => _player.seek(to);
void _skipBack() => _player.seek(_player.state.position - const Duration(seconds: 10));
void _skipForward() => _player.seek(_player.state.position + const Duration(seconds: 10));
void _setSpeed(double s) { setState(() => _speed = s); _player.setRate(s); }
@override
Widget build(BuildContext context) {
final pt = widget.pt;
if (_loading) return _buildLoading(pt);
return StreamBuilder<bool>(
stream: _player.stream.playing,
builder: (_, playSnap) => StreamBuilder<Duration>(
stream: _player.stream.position,
builder: (_, posSnap) => StreamBuilder<Duration>(
stream: _player.stream.duration,
builder: (_, durSnap) => StreamBuilder<double>(
stream: _player.stream.volume,
builder: (_, volSnap) {
final playing = playSnap.data ?? false;
final pos = posSnap.data ?? Duration.zero;
final dur = durSnap.data ?? Duration.zero;
final vol = (volSnap.data ?? 100.0) / 100.0;
return widget.isVoice
? _buildCompact(pt, playing, pos, dur)
: _buildDefaultBars(pt, playing, pos, dur, vol);
},
),
),
),
);
}
Widget _buildLoading(PyramidTheme pt) => Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
constraints: const BoxConstraints(maxWidth: 340),
decoration: BoxDecoration(
color: pt.bg1, border: Border.all(color: pt.border),
borderRadius: BorderRadius.circular(pt.rLg),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
SizedBox(width: 32, height: 32,
child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
const SizedBox(width: 12),
Text('Lade…', style: TextStyle(color: pt.fgMuted, fontSize: 12)),
]),
);
// ── Compact: Sprachnachricht ───────────────────────────────────────────────
Widget _buildCompact(PyramidTheme pt, bool playing, Duration pos, Duration dur) {
final progress = dur.inMilliseconds == 0
? 0.0 : pos.inMilliseconds / dur.inMilliseconds;
final title = widget.filename.contains('.')
? widget.filename.split('.').first : widget.filename;
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
constraints: const BoxConstraints(maxWidth: 340),
decoration: BoxDecoration(
color: pt.bg1, border: Border.all(color: pt.border),
borderRadius: BorderRadius.circular(pt.rLg),
),
child: Row(children: [
_AArtwork(size: 36, accent: pt.accent),
const SizedBox(width: 10),
Flexible(child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
Text('Sprachnachricht', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
],
)),
const SizedBox(width: 10),
_APlayButton(playing: playing, onTap: () => playing ? _player.pause() : _player.play(),
size: 32, accent: pt.accent, accentGlow: pt.accentGlow),
const SizedBox(width: 10),
Text(_fmt(pos), style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)),
const SizedBox(width: 8),
Expanded(child: _AScrubber(progress: progress, accent: pt.accent, track: pt.bg3,
onSeek: (f) => _seek(Duration(milliseconds: (dur.inMilliseconds * f).round())))),
const SizedBox(width: 8),
Text(_fmt(dur), style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)),
]),
);
}
// ── DefaultBars: Audiodatei mit Waveform ───────────────────────────────────
Widget _buildDefaultBars(PyramidTheme pt, bool playing, Duration pos, Duration dur, double vol) {
final progress = dur.inMilliseconds == 0 ? 0.0 : pos.inMilliseconds / dur.inMilliseconds;
final nameParts = widget.filename.split('.');
final title = nameParts.length > 1
? nameParts.sublist(0, nameParts.length - 1).join('.') : widget.filename;
final ext = nameParts.length > 1 ? nameParts.last.toUpperCase() : 'AUDIO';
final remaining = pos <= dur ? dur - pos : Duration.zero;
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
constraints: const BoxConstraints(maxWidth: 380),
decoration: BoxDecoration(
color: pt.bg1, border: Border.all(color: pt.border),
borderRadius: BorderRadius.circular(pt.rLg),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
// ── Header ───────────────────────────────────────────────────────────
Row(children: [
_AArtwork(size: 48, accent: pt.accent),
const SizedBox(width: 14),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
Text(ext, style: TextStyle(color: pt.fgMuted, fontSize: 12)),
],
)),
]),
const SizedBox(height: 14),
// ── Waveform ─────────────────────────────────────────────────────────
LayoutBuilder(builder: (_, c) => GestureDetector(
onTapDown: (d) {
final f = (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0);
_seek(Duration(milliseconds: (dur.inMilliseconds * f).round()));
},
onPanUpdate: (d) {
final f = (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0);
_seek(Duration(milliseconds: (dur.inMilliseconds * f).round()));
},
child: SizedBox(height: 40, child: CustomPaint(
painter: _ChunkyBarsPainter(
progress: progress,
played: pt.accent, unplayed: pt.bg3, cursor: pt.fg,
),
size: Size.infinite,
)),
)),
const SizedBox(height: 8),
// ── Zeitanzeige ──────────────────────────────────────────────────────
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(_fmt(pos), style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)),
Text('${_fmt(remaining)}', style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)),
]),
const SizedBox(height: 14),
// ── Steuerung ────────────────────────────────────────────────────────
Row(children: [
Icon(Icons.volume_up_outlined, size: 16, color: pt.fgMuted),
const SizedBox(width: 6),
SizedBox(width: 64, child: _AMiniBar(value: vol, track: pt.bg3, fill: pt.fgMuted)),
const Spacer(),
_AIconBtn(icon: Icons.replay_10, color: pt.fgMuted, rSm: pt.rSm, onTap: _skipBack),
const SizedBox(width: 4),
_APlayButton(
playing: playing,
onTap: () => playing ? _player.pause() : _player.play(),
size: 40, accent: pt.accent, accentGlow: pt.accentGlow,
),
const SizedBox(width: 4),
_AIconBtn(icon: Icons.forward_10, color: pt.fgMuted, rSm: pt.rSm, onTap: _skipForward),
const Spacer(),
..._speeds.map((s) => _ASpeedPill(
label: '${s == s.toInt() ? s.toInt() : s}x',
active: _speed == s,
accent: pt.accent, accentSoft: pt.accentSoft,
bg: pt.bg2, fgMuted: pt.fgMuted, rSm: pt.rSm,
onTap: () => _setSpeed(s),
)),
]),
]),
);
}
}
// ─── Audio: Bausteine ────────────────────────────────────────────────────────
class _AArtwork extends StatelessWidget {
final double size;
final Color accent;
const _AArtwork({required this.size, required this.accent});
@override
Widget build(BuildContext context) {
final hsl = HSLColor.fromColor(accent);
final c1 = hsl.withLightness((hsl.lightness - 0.08).clamp(0.0, 1.0)).toColor();
final c2 = hsl.withHue((hsl.hue + 30) % 360)
.withLightness((hsl.lightness - 0.18).clamp(0.0, 1.0)).toColor();
return Container(
width: size, height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(size * 0.3),
gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [c1, c2]),
),
child: Icon(Icons.music_note, color: Colors.white.withOpacity(0.85), size: size * 0.48),
);
}
}
class _APlayButton extends StatelessWidget {
final bool playing;
final VoidCallback? onTap;
final double size;
final Color accent, accentGlow;
const _APlayButton({required this.playing, this.onTap, required this.size,
required this.accent, required this.accentGlow});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: size, height: size,
decoration: BoxDecoration(
color: accent, shape: BoxShape.circle,
boxShadow: [BoxShadow(color: accentGlow, blurRadius: 16, offset: const Offset(0, 4))],
),
child: Icon(playing ? Icons.pause : Icons.play_arrow,
color: PyramidColors.accentFg, size: size * 0.48),
),
);
}
}
class _AIconBtn extends StatelessWidget {
final IconData icon;
final Color color;
final double rSm;
final VoidCallback? onTap;
const _AIconBtn({required this.icon, required this.color, required this.rSm, this.onTap});
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(rSm),
child: SizedBox(width: 32, height: 32,
child: Icon(icon, size: 18, color: color)),
);
}
class _ASpeedPill extends StatelessWidget {
final String label;
final bool active;
final VoidCallback? onTap;
final Color accent, accentSoft, bg, fgMuted;
final double rSm;
const _ASpeedPill({required this.label, this.active = false, this.onTap,
required this.accent, required this.accentSoft, required this.bg,
required this.fgMuted, required this.rSm});
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: active ? accentSoft : bg,
borderRadius: BorderRadius.circular(rSm),
),
child: Text(label, style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w500,
color: active ? accent : fgMuted,
letterSpacing: 0.2,
)),
),
);
}
class _AScrubber extends StatelessWidget {
final double progress;
final Color accent, track;
final ValueChanged<double>? onSeek;
const _AScrubber({required this.progress, required this.accent, required this.track, this.onSeek});
@override
Widget build(BuildContext context) => LayoutBuilder(builder: (_, c) => GestureDetector(
onTapDown: (d) => onSeek?.call((d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)),
onPanUpdate: (d) => onSeek?.call((d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)),
child: SizedBox(height: 14, child: Stack(alignment: Alignment.centerLeft, children: [
Container(height: 3, decoration: BoxDecoration(color: track, borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(
widthFactor: progress,
child: Container(height: 3, decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2))),
),
])),
));
}
class _AMiniBar extends StatelessWidget {
final double value;
final Color track, fill;
const _AMiniBar({required this.value, required this.track, required this.fill});
@override
Widget build(BuildContext context) => Stack(alignment: Alignment.centerLeft, children: [
Container(height: 3, decoration: BoxDecoration(color: track, borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(
widthFactor: value.clamp(0.0, 1.0),
child: Container(height: 3, decoration: BoxDecoration(color: fill, borderRadius: BorderRadius.circular(2))),
),
]);
}
class _ChunkyBarsPainter extends CustomPainter {
final double progress;
final Color played, unplayed, cursor;
static const int _count = 56;
static final List<double> _heights = _seed();
static List<double> _seed() {
final r = math.Random(7);
return List.generate(_count, (i) {
final env = math.sin((i / _count) * math.pi) * 0.6 + 0.4;
return 0.2 + r.nextDouble() * 0.8 * env;
});
}
const _ChunkyBarsPainter({required this.progress, required this.played,
required this.unplayed, required this.cursor});
@override
void paint(Canvas canvas, Size s) {
const gap = 2.0;
final barW = (s.width - gap * (_count - 1)) / _count;
final cur = (progress * _count).floor();
for (int i = 0; i < _count; i++) {
final h = _heights[i] * s.height;
final x = i * (barW + gap);
final y = (s.height - h) / 2;
final color = i == cur ? cursor : (i < cur ? played : unplayed);
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(x, y, barW, h), const Radius.circular(1)),
Paint()..color = color,
);
}
}
@override
bool shouldRepaint(_ChunkyBarsPainter old) =>
old.progress != progress || old.played != played;
}
// ─── Video Player ─────────────────────────────────────────────────────────────
//
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide)
class PyramidVideoPlayer extends StatefulWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
final String senderName;
const PyramidVideoPlayer({
super.key,
required this.bytes,
required this.filename,
required this.pt,
this.senderName = '',
});
@override
State<PyramidVideoPlayer> createState() => _PyramidVideoPlayerState();
}
class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
late final Player _player;
late final VideoController _controller;
bool _loading = true;
double _speed = 1.0;
String _quality = 'Auto';
static const _speeds = [0.5, 1.0, 1.25, 1.5, 2.0];
static const _qualities = ['Auto', '1080p', '720p', '480p', '360p'];
@override
void initState() {
super.initState();
_player = Player();
_controller = VideoController(_player);
_init();
}
Future<void> _init() async {
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/pyr_video_${widget.bytes.hashCode}.$ext');
await file.writeAsBytes(widget.bytes);
await _player.open(Media('file://${file.path}'), play: false);
if (mounted) setState(() => _loading = false);
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
String _fmt(Duration d) {
final m = d.inMinutes.remainder(60);
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
void _openFullscreen() => MediaViewer.show(
context,
senderName: widget.senderName,
filename: widget.filename,
bytes: widget.bytes,
isVideo: true,
);
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final title = widget.filename.contains('.')
? widget.filename.split('.').first : widget.filename;
if (_loading) {
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
constraints: const BoxConstraints(maxWidth: 360),
height: 200,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(pt.rLg),
border: Border.all(color: pt.border),
),
child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
);
}
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
constraints: const BoxConstraints(maxWidth: 420),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(pt.rLg),
border: Border.all(color: pt.border),
),
clipBehavior: Clip.hardEdge,
child: AspectRatio(
aspectRatio: 16 / 9,
child: StreamBuilder<bool>(
stream: _player.stream.playing,
builder: (_, playSnap) => StreamBuilder<Duration>(
stream: _player.stream.position,
builder: (_, posSnap) => StreamBuilder<Duration>(
stream: _player.stream.duration,
builder: (_, durSnap) {
final playing = playSnap.data ?? false;
final pos = posSnap.data ?? Duration.zero;
final dur = durSnap.data ?? Duration.zero;
final progress = dur.inMilliseconds == 0
? 0.0 : pos.inMilliseconds / dur.inMilliseconds;
return GestureDetector(
onTap: () => playing ? _player.pause() : _player.play(),
child: Stack(fit: StackFit.expand, children: [
// Video
Video(controller: _controller, controls: NoVideoControls),
// Top: Titelleiste mit Farbverlauf
Align(
alignment: Alignment.topCenter,
child: _VTopBar(title: title, onFullscreen: _openFullscreen),
),
// Center: Play-Button wenn pausiert
if (!playing)
const Center(child: _VCenterPlay()),
// Bottom: Steuerleiste (minimal = immer sichtbar)
Align(
alignment: Alignment.bottomCenter,
child: _VBottomBar(
playing: playing, pos: pos, dur: dur,
progress: progress, speed: _speed, quality: _quality,
speeds: _speeds, qualities: _qualities, fmt: _fmt, pt: pt,
onTogglePlay: () => playing ? _player.pause() : _player.play(),
onSeek: (d) => _player.seek(d),
onSkipBack: () => _player.seek(pos - const Duration(seconds: 10)),
onSkipForward: () => _player.seek(pos + const Duration(seconds: 10)),
onSpeed: (s) { setState(() => _speed = s); _player.setRate(s); },
onQuality: (q) => setState(() => _quality = q),
onFullscreen: _openFullscreen,
),
),
]),
);
},
),
),
),
),
);
}
}
// ─── Video: Bausteine ────────────────────────────────────────────────────────
class _VTopBar extends StatelessWidget {
final String title;
final VoidCallback? onFullscreen;
const _VTopBar({required this.title, this.onFullscreen});
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(14, 12, 8, 20),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter, end: Alignment.bottomCenter,
colors: [Color(0x99000000), Colors.transparent],
),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
Expanded(child: Text(title, style: const TextStyle(
color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500,
), overflow: TextOverflow.ellipsis)),
_VOverlayBtn(icon: Icons.fullscreen, onTap: onFullscreen),
]),
);
}
class _VCenterPlay extends StatelessWidget {
const _VCenterPlay();
@override
Widget build(BuildContext context) => Container(
width: 56, height: 56,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.10),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.18)),
),
child: const Icon(Icons.play_arrow, color: Colors.white, size: 26),
);
}
class _VBottomBar extends StatelessWidget {
final bool playing;
final Duration pos, dur;
final double progress, speed;
final String quality;
final List<double> speeds;
final List<String> qualities;
final String Function(Duration) fmt;
final PyramidTheme pt;
final VoidCallback onTogglePlay, onSkipBack, onSkipForward, onFullscreen;
final ValueChanged<Duration> onSeek;
final ValueChanged<double> onSpeed;
final ValueChanged<String> onQuality;
const _VBottomBar({
required this.playing, required this.pos, required this.dur,
required this.progress, required this.speed, required this.quality,
required this.speeds, required this.qualities, required this.fmt,
required this.pt, required this.onTogglePlay, required this.onSeek,
required this.onSkipBack, required this.onSkipForward,
required this.onSpeed, required this.onQuality, required this.onFullscreen,
});
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(12, 16, 12, 12),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter, end: Alignment.topCenter,
colors: [Color(0xCC000000), Colors.transparent],
),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
_VScrubber(progress: progress, dur: dur, onSeek: onSeek, accent: pt.accent),
const SizedBox(height: 8),
Row(children: [
_VPlayMini(playing: playing, onTap: onTogglePlay),
_VOverlayBtn(icon: Icons.replay_10, onTap: onSkipBack),
_VOverlayBtn(icon: Icons.forward_10, onTap: onSkipForward),
const SizedBox(width: 6),
Text('${fmt(pos)} / ${fmt(dur)}',
style: const TextStyle(color: Colors.white70, fontSize: 10, letterSpacing: 0.4)),
const Spacer(),
_VSettingsButton(
speed: speed, quality: quality,
speeds: speeds, qualities: qualities,
onSpeed: onSpeed, onQuality: onQuality,
),
_VOverlayBtn(icon: Icons.fullscreen, onTap: onFullscreen),
]),
]),
);
}
class _VScrubber extends StatelessWidget {
final double progress;
final Duration dur;
final ValueChanged<Duration> onSeek;
final Color accent;
const _VScrubber({required this.progress, required this.dur,
required this.onSeek, required this.accent});
@override
Widget build(BuildContext context) => LayoutBuilder(builder: (_, c) => GestureDetector(
onTapDown: (d) => onSeek(Duration(
milliseconds: (dur.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round())),
onPanUpdate: (d) => onSeek(Duration(
milliseconds: (dur.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round())),
child: SizedBox(height: 16, child: Stack(alignment: Alignment.centerLeft, children: [
Container(height: 3, decoration: BoxDecoration(
color: Colors.white.withOpacity(0.20), borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(widthFactor: progress, child: Container(height: 3,
decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2)))),
Positioned(
left: c.maxWidth * progress.clamp(0.0, 1.0) - 5,
child: Container(width: 10, height: 10,
decoration: BoxDecoration(color: accent, shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.4), blurRadius: 0, spreadRadius: 2)])),
),
])),
));
}
class _VPlayMini extends StatelessWidget {
final bool playing;
final VoidCallback? onTap;
const _VPlayMini({required this.playing, this.onTap});
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
width: 34, height: 34,
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
child: Icon(playing ? Icons.pause : Icons.play_arrow,
color: const Color(0xFF0A0A0C), size: 16),
),
);
}
class _VOverlayBtn extends StatelessWidget {
final IconData icon;
final VoidCallback? onTap;
const _VOverlayBtn({required this.icon, this.onTap});
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: SizedBox(width: 32, height: 32,
child: Icon(icon, size: 17, color: Colors.white.withOpacity(0.85))),
);
}
class _VSettingsButton extends StatelessWidget {
final double speed;
final String quality;
final List<double> speeds;
final List<String> qualities;
final ValueChanged<double> onSpeed;
final ValueChanged<String> onQuality;
const _VSettingsButton({required this.speed, required this.quality,
required this.speeds, required this.qualities,
required this.onSpeed, required this.onQuality});
String get _speedLabel {
final s = speed;
return '${s == s.toInt() ? s.toInt() : s}x';
}
@override
Widget build(BuildContext context) => PopupMenuButton<void>(
tooltip: 'Einstellungen',
offset: const Offset(0, -8),
position: PopupMenuPosition.over,
color: const Color(0xF014141A),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.white.withOpacity(0.1)),
),
itemBuilder: (_) => [PopupMenuItem<void>(
enabled: false, padding: EdgeInsets.zero,
child: _VSettingsMenu(
speed: _speedLabel, quality: quality,
speeds: speeds, qualities: qualities,
onSpeed: onSpeed, onQuality: onQuality,
),
)],
child: SizedBox(width: 32, height: 32,
child: Icon(Icons.settings_outlined, size: 16, color: Colors.white.withOpacity(0.85))),
);
}
class _VSettingsMenu extends StatefulWidget {
final String speed, quality;
final List<double> speeds;
final List<String> qualities;
final ValueChanged<double> onSpeed;
final ValueChanged<String> onQuality;
const _VSettingsMenu({required this.speed, required this.quality,
required this.speeds, required this.qualities,
required this.onSpeed, required this.onQuality});
@override
State<_VSettingsMenu> createState() => _VSettingsMenuState();
}
class _VSettingsMenuState extends State<_VSettingsMenu> {
String? _section; // null | 'speed' | 'quality'
static const _fg = Color(0xE5FFFFFF);
static const _dim = Color(0x99FFFFFF);
@override
Widget build(BuildContext context) {
if (_section == null) {
return SizedBox(
width: 190,
child: Column(mainAxisSize: MainAxisSize.min, children: [
_row('Geschwindigkeit', widget.speed, () => setState(() => _section = 'speed')),
_row('Qualität', widget.quality, () => setState(() => _section = 'quality')),
]),
);
}
final isSpeed = _section == 'speed';
return SizedBox(
width: 170,
child: Column(mainAxisSize: MainAxisSize.min, children: [
InkWell(
onTap: () => setState(() => _section = null),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.arrow_back, size: 12, color: _dim),
const SizedBox(width: 8),
Text(isSpeed ? 'GESCHWINDIGKEIT' : 'QUALITÄT',
style: const TextStyle(color: _dim, fontSize: 10, letterSpacing: 0.8)),
]),
),
),
Container(height: 1, color: Colors.white.withOpacity(0.08)),
if (isSpeed)
...widget.speeds.map((s) {
final lbl = '${s == s.toInt() ? s.toInt() : s}x';
return _item(lbl, lbl == widget.speed, () => widget.onSpeed(s));
})
else
...widget.qualities.map((q) => _item(q, q == widget.quality, () => widget.onQuality(q))),
]),
);
}
Widget _row(String label, String value, VoidCallback onTap) => InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(label, style: const TextStyle(color: _fg, fontSize: 12)),
const SizedBox(width: 16),
const Spacer(),
Text(value, style: const TextStyle(color: _dim, fontSize: 11, letterSpacing: 0.3)),
const SizedBox(width: 4),
const Icon(Icons.chevron_right, size: 14, color: _dim),
]),
),
);
Widget _item(String label, bool active, VoidCallback onTap) => InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
SizedBox(width: 18, child: active
? const Icon(Icons.check, size: 14, color: PyramidColors.accentAmber)
: null),
Text(label, style: TextStyle(
color: active ? PyramidColors.accentAmber : _fg, fontSize: 12,
)),
]),
),
);
}
// ─── Fullscreen Video Player ──────────────────────────────────────────────────
// Verwendet AdaptiveVideoControls für native System-Fullscreen-Integration
class FullscreenVideoPlayer extends StatefulWidget {
final Uint8List bytes;
final String filename;
const FullscreenVideoPlayer({super.key, required this.bytes, required this.filename});
@override
State<FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState();
}
class _FullscreenVideoPlayerState extends State<FullscreenVideoPlayer> {
late final Player _player;
late final VideoController _controller;
bool _loading = true;
@override
void initState() {
super.initState();
_player = Player();
_controller = VideoController(_player);
_init();
}
Future<void> _init() async {
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/pyr_vfull_${widget.bytes.hashCode}.$ext');
await file.writeAsBytes(widget.bytes);
await _player.open(Media('file://${file.path}'), play: true);
if (mounted) setState(() => _loading = false);
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_loading) return const Center(child: CircularProgressIndicator(color: Colors.white));
return Video(controller: _controller, controls: AdaptiveVideoControls);
}
}
+255
View File
@@ -0,0 +1,255 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/media_player.dart';
import 'package:pyramid/utils/gif_favorite_service.dart';
/// Overlay viewer for images, GIFs and videos.
/// Uses a semi-transparent backdrop; tapping outside the content closes it.
class MediaViewer extends StatefulWidget {
final String senderName;
final Uint8List? bytes;
final String? networkUrl;
final String filename;
final bool isVideo;
final Event? event;
const MediaViewer({
super.key,
required this.senderName,
required this.filename,
this.bytes,
this.networkUrl,
this.isVideo = false,
this.event,
});
static Future<void> show(
BuildContext context, {
required String senderName,
required String filename,
Uint8List? bytes,
String? networkUrl,
bool isVideo = false,
Event? event,
}) {
return showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: 'Schließen',
barrierColor: Colors.black.withAlpha(178),
transitionDuration: const Duration(milliseconds: 180),
transitionBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
senderName: senderName,
filename: filename,
bytes: bytes,
networkUrl: networkUrl,
isVideo: isVideo,
event: event,
),
);
}
@override
State<MediaViewer> createState() => _MediaViewerState();
}
class _MediaViewerState extends State<MediaViewer> {
final _tc = TransformationController();
bool _controlsVisible = true;
bool _saving = false;
bool _savingSticker = false;
@override
void dispose() {
_tc.dispose();
super.dispose();
}
void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
void _resetZoom() => _tc.value = Matrix4.identity();
Future<void> _saveAsSticker() async {
final event = widget.event;
if (event == null || _savingSticker) return;
setState(() => _savingSticker = true);
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final ok = await GifFavoriteService.addImageAsStickerFavorite(event);
messenger?.showSnackBar(SnackBar(
content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'),
duration: const Duration(seconds: 2),
));
} finally {
if (mounted) setState(() => _savingSticker = false);
}
}
Future<void> _save() async {
final b = widget.bytes;
if (b == null || _saving) return;
setState(() => _saving = true);
try {
Directory? dir;
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
dir = await getDownloadsDirectory();
}
dir ??= await getTemporaryDirectory();
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'jpg';
final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext';
final file = File('${dir.path}/$name');
await file.writeAsBytes(b);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Gespeichert: ${file.path}'),
duration: const Duration(seconds: 4),
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler: $e')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final isVideo = widget.isVideo && widget.bytes != null;
final size = MediaQuery.sizeOf(context);
final header = AnimatedOpacity(
opacity: _controlsVisible ? 1 : 0,
duration: const Duration(milliseconds: 180),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.transparent],
),
),
child: SafeArea(
bottom: false,
child: Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded, color: Colors.white),
tooltip: 'Schließen',
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.senderName,
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600)),
Text(widget.filename,
style: const TextStyle(color: Colors.white60, fontSize: 12),
overflow: TextOverflow.ellipsis),
],
),
),
if (widget.event != null && !isVideo)
IconButton(
onPressed: _savingSticker ? null : _saveAsSticker,
tooltip: 'Als Sticker speichern',
icon: _savingSticker
? const SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.sticky_note_2_outlined, color: Colors.white),
),
if (widget.bytes != null)
IconButton(
onPressed: _saving ? null : _save,
tooltip: 'Speichern',
icon: _saving
? const SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.download_rounded, color: Colors.white),
),
if (!isVideo)
IconButton(
onPressed: _resetZoom,
tooltip: 'Zoom zurücksetzen',
icon: const Icon(Icons.zoom_out_map_rounded, color: Colors.white),
),
],
),
),
),
),
);
// Content area — constrained so the transparent Material outside it lets
// pointer events through to the barrier (which dismisses on tap).
final contentMaxW = (size.width - 48).clamp(200.0, 1200.0);
final contentMaxH = (size.height - 48).clamp(200.0, 900.0);
if (isVideo) {
return Material(
type: MaterialType.transparency,
child: Center(
child: SizedBox(
width: contentMaxW,
height: contentMaxH,
child: Stack(
fit: StackFit.expand,
children: [
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
Positioned(top: 0, left: 0, right: 0, child: header),
],
),
),
),
);
}
Widget img;
if (widget.bytes != null) {
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
} else if (widget.networkUrl != null) {
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
} else {
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
}
return Material(
type: MaterialType.transparency,
child: Center(
child: SizedBox(
width: contentMaxW,
height: contentMaxH,
child: GestureDetector(
onTap: _toggle,
child: Stack(
fit: StackFit.expand,
children: [
InteractiveViewer(
transformationController: _tc,
minScale: 0.5,
maxScale: 10.0,
child: Center(child: img),
),
Positioned(top: 0, left: 0, right: 0, child: header),
],
),
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/chat_provider.dart';
class PinnedMessagesPanel extends ConsumerWidget {
final String roomId;
final VoidCallback onClose;
const PinnedMessagesPanel({
super.key,
required this.roomId,
required this.onClose,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final room = ref.watch(roomProvider(roomId));
final timeline = ref.watch(timelineProvider(roomId)).valueOrNull;
if (room == null) return const SizedBox.shrink();
final pinnedIds = List<String>.from(
room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
);
final pinned = pinnedIds
.map((id) => timeline?.events.where((e) => e.eventId == id).firstOrNull)
.where((e) => e != null)
.cast<Event>()
.toList();
return Container(
width: 320,
decoration: BoxDecoration(
color: pt.bg1,
border: Border(left: BorderSide(color: pt.border)),
),
child: Column(
children: [
// Header
Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: pt.border)),
),
child: Row(
children: [
Icon(Icons.push_pin_rounded, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Expanded(
child: Text(
'Angepinnte Nachrichten',
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
GestureDetector(
onTap: onClose,
child: Icon(Icons.close_rounded, size: 18, color: pt.fgDim),
),
],
),
),
// List
Expanded(
child: pinnedIds.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.push_pin_outlined, size: 40, color: pt.fgDim),
const SizedBox(height: 12),
Text(
'Keine angepinnten Nachrichten',
style: TextStyle(color: pt.fgMuted, fontSize: 14),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: pinned.isEmpty ? pinnedIds.length : pinned.length,
itemBuilder: (ctx, i) {
if (pinned.isEmpty) {
return _PinnedPlaceholder(pt: pt);
}
return _PinnedMessageCard(
event: pinned[i],
room: room,
pt: pt,
onUnpin: () => _unpin(room, pinned[i].eventId),
);
},
),
),
],
),
);
}
void _unpin(Room room, String eventId) {
final pinned = List<String>.from(
room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
);
pinned.remove(eventId);
room.setPinnedEvents(pinned).catchError((_) => '');
}
}
class _PinnedPlaceholder extends StatelessWidget {
final PyramidTheme pt;
const _PinnedPlaceholder({required this.pt});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: Text(
'Nachricht wird geladen…',
style: TextStyle(color: pt.fgDim, fontSize: 13, fontStyle: FontStyle.italic),
),
);
}
}
class _PinnedMessageCard extends StatelessWidget {
final Event event;
final Room room;
final PyramidTheme pt;
final VoidCallback onUnpin;
const _PinnedMessageCard({
required this.event,
required this.room,
required this.pt,
required this.onUnpin,
});
@override
Widget build(BuildContext context) {
final sender = event.senderFromMemoryOrFallback.calcDisplayname();
final time = _formatTime(event.originServerTs);
final body = event.body;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.push_pin_rounded, size: 12, color: pt.accent),
const SizedBox(width: 6),
Expanded(
child: Text(
sender,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
Text(time, style: TextStyle(color: pt.fgDim, fontSize: 11)),
const SizedBox(width: 6),
GestureDetector(
onTap: onUnpin,
child: Tooltip(
message: 'Entpinnen',
child: Icon(Icons.push_pin_outlined, size: 14, color: pt.fgDim),
),
),
],
),
const SizedBox(height: 6),
Text(
body,
style: TextStyle(color: pt.fgMuted, fontSize: 13),
maxLines: 5,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
String _formatTime(DateTime t) {
final now = DateTime.now();
final diff = now.difference(t);
if (diff.inDays == 0) {
return '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
}
if (diff.inDays < 7) return 'vor ${diff.inDays}d';
return '${t.day}.${t.month}.${t.year}';
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:shared_preferences/shared_preferences.dart';
class ReactionService {
static const _key = 'recent_reactions';
static const _maxRecent = 6;
static final _defaultReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
static List<String> _recent = [];
static bool _loaded = false;
static Future<void> load() async {
if (_loaded) return;
final prefs = await SharedPreferences.getInstance();
_recent = prefs.getStringList(_key) ?? [];
_loaded = true;
}
static List<String> get quickReactions {
if (_recent.isEmpty) return _defaultReactions;
// Fill up to 6 with defaults not already in recent
final result = List<String>.from(_recent);
for (final d in _defaultReactions) {
if (result.length >= _maxRecent) break;
if (!result.contains(d)) result.add(d);
}
return result.take(_maxRecent).toList();
}
static Future<void> recordUsed(String emoji) async {
await load();
_recent.remove(emoji);
_recent.insert(0, emoji);
if (_recent.length > _maxRecent) _recent = _recent.sublist(0, _maxRecent);
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_key, _recent);
}
}
+263
View File
@@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/pyramid_logo.dart';
class MembersPanel extends ConsumerWidget {
final String roomId;
/// When set, a close button is shown in the header (used on mobile/overlay).
final VoidCallback? onClose;
/// Panel width. Defaults to 280 (desktop side panel).
final double width;
const MembersPanel({super.key, required this.roomId, this.onClose, this.width = 280});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final client = ref.watch(matrixClientProvider).valueOrNull;
final room = client?.getRoomById(roomId);
final members = room?.getParticipants() ?? [];
// Show all members; presence info may not be available
final online = members.toList();
final offline = <User>[];
return Container(
width: width,
decoration: BoxDecoration(
color: pt.bg1,
border: Border(left: BorderSide(color: pt.border)),
),
child: Column(
children: [
Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: pt.border)),
),
child: Row(
children: [
Icon(Icons.group_rounded, size: 16, color: pt.fg),
const SizedBox(width: 8),
Text(
'Members',
style: TextStyle(
color: pt.fg,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 6),
Text(
'${members.length}',
style: TextStyle(color: pt.fgDim, fontSize: 14),
),
if (onClose != null) ...[
const Spacer(),
GestureDetector(
onTap: onClose,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(Icons.close_rounded, size: 20, color: pt.fgMuted),
),
),
],
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.all(8),
children: [
if (online.isNotEmpty) ...[
_SectionLabel('Online — ${online.length}', pt),
...online.map((m) => _MemberItem(member: m, pt: pt)),
],
if (offline.isNotEmpty) ...[
const SizedBox(height: 14),
_SectionLabel('Offline — ${offline.length}', pt),
...offline.map((m) => _MemberItem(
member: m,
pt: pt,
isOffline: true,
)),
],
if (members.isEmpty) ...[
// Show placeholder members when room has none loaded
_SectionLabel('Online — 0', pt),
_SectionLabel('', pt),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 24),
child: Text(
'No members loaded',
style: TextStyle(color: pt.fgDim, fontSize: 12),
),
),
),
],
],
),
),
],
),
);
}
}
class _SectionLabel extends StatelessWidget {
final String text;
final PyramidTheme pt;
const _SectionLabel(this.text, this.pt);
@override
Widget build(BuildContext context) {
if (text.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(
text.toUpperCase(),
style: TextStyle(
color: pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.04 * 11,
),
),
);
}
}
class _MemberItem extends StatefulWidget {
final User member;
final PyramidTheme pt;
final bool isOffline;
const _MemberItem({
required this.member,
required this.pt,
this.isOffline = false,
});
@override
State<_MemberItem> createState() => _MemberItemState();
}
class _MemberItemState extends State<_MemberItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final member = widget.member;
final name = member.calcDisplayname();
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
final color = _colorFromName(name);
final status = widget.isOffline ? 'offline' : _memberStatus(member);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
decoration: BoxDecoration(
color: _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
children: [
Opacity(
opacity: widget.isOffline ? 0.5 : 1,
child: Stack(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Center(
child: Text(
initial,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
),
if (!widget.isOffline)
Positioned(
bottom: -2,
right: -2,
child: PresenceDot(
status: status,
size: 9,
borderColor: pt.bgHover,
),
),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
color: widget.isOffline ? pt.fgMuted : pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
}
String _memberStatus(User m) {
try {
// ignore: deprecated_member_use
final p = m.room.client.presences[m.id];
if (p == null) return 'offline';
// Veraltete "online"-Presence vom Server nicht blind übernehmen:
// nur online zeigen, wenn die letzte Aktivität frisch ist.
final last = p.lastActiveTimestamp;
final fresh = last != null &&
DateTime.now().difference(last) < const Duration(minutes: 5);
return switch (p.presence) {
PresenceType.online when p.currentlyActive == true || fresh => 'online',
PresenceType.online => 'offline',
PresenceType.unavailable => 'away',
_ => 'offline',
};
} catch (_) {
return 'offline';
}
}
Color _colorFromName(String name) {
const colors = [
Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981),
Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B),
];
if (name.isEmpty) return colors[0];
return colors[name.codeUnitAt(0) % colors.length];
}
}
-148
View File
@@ -1,148 +0,0 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/widgets/mxc_image.dart';
class RoomListItem extends StatelessWidget {
final Room room;
final VoidCallback onTap;
const RoomListItem({super.key, required this.room, required this.onTap});
@override
Widget build(BuildContext context) {
final unread = room.notificationCount;
final lastEvent = room.lastEvent;
final theme = Theme.of(context);
return ListTile(
onTap: onTap,
leading: _RoomAvatar(room: room),
title: Text(
room.getLocalizedDisplayname(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: unread > 0
? theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold)
: theme.textTheme.bodyLarge,
),
subtitle: lastEvent != null
? Text(
_previewText(lastEvent),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
)
: null,
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (lastEvent != null)
Text(
_formatTime(lastEvent.originServerTs),
style: theme.textTheme.labelSmall?.copyWith(
color: unread > 0
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
),
if (unread > 0) ...[
const SizedBox(height: 4),
_UnreadBadge(count: unread),
],
],
),
);
}
String _previewText(Event event) {
if (event.type == EventTypes.Message) {
final body = event.body;
if (body.isNotEmpty) return body;
}
return event.type;
}
String _formatTime(DateTime time) {
final now = DateTime.now();
if (time.day == now.day &&
time.month == now.month &&
time.year == now.year) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
return '${time.day.toString().padLeft(2, '0')}.${time.month.toString().padLeft(2, '0')}';
}
}
class _RoomAvatar extends StatelessWidget {
final Room room;
const _RoomAvatar({required this.room});
@override
Widget build(BuildContext context) {
final avatar = room.avatar;
final name = room.getLocalizedDisplayname();
final initials = name.isNotEmpty ? name[0].toUpperCase() : '?';
final color = _colorForName(name, Theme.of(context).colorScheme);
return CircleAvatar(
radius: 24,
backgroundColor: color,
child: ClipOval(
child: avatar != null
? MxcImage(
mxcUri: avatar,
width: 48,
height: 48,
placeholder: (_) => Text(
initials,
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
),
)
: Text(
initials,
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
),
),
);
}
Color _colorForName(String name, ColorScheme scheme) {
final colors = [
scheme.primary,
scheme.secondary,
scheme.tertiary,
Colors.teal,
Colors.indigo,
Colors.orange,
];
final hash = name.codeUnits.fold(0, (a, b) => a + b);
return colors[hash % colors.length];
}
}
class _UnreadBadge extends StatelessWidget {
final int count;
const _UnreadBadge({required this.count});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(10),
),
child: Text(
count > 99 ? '99+' : '$count',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
);
}
}
-55
View File
@@ -1,55 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:pyramid/features/rooms/room_list_item.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart';
class RoomsPage extends ConsumerWidget {
const RoomsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final roomsAsync = ref.watch(roomListProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Pyramid'),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.go('/settings'),
),
],
),
body: roomsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Fehler: $e')),
data: (rooms) {
if (rooms.isEmpty) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.forum_outlined, size: 64),
SizedBox(height: 16),
Text('Noch keine Räume'),
],
),
);
}
return ListView.separated(
itemCount: rooms.length,
separatorBuilder: (context, i) => const Divider(height: 1, indent: 72),
itemBuilder: (context, i) {
final room = rooms[i];
return RoomListItem(
room: room,
onTap: () => context.go('/chat/${Uri.encodeComponent(room.id)}'),
);
},
);
},
),
);
}
}
+659 -29
View File
@@ -65,18 +65,28 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
activeSpaceId != 'dms' &&
activeSpaceId != 'rooms';
final client = ref.watch(matrixClientProvider).valueOrNull;
final forceJoined = ref.watch(forceJoinedSpacesProvider);
final activeSpaceRoom =
isRealSpace ? client?.getRoomById(activeSpaceId) : null;
final isInvitedSpace = activeSpaceRoom?.membership == Membership.invite &&
!forceJoined.contains(activeSpaceId);
return Container(
color: pt.bg1,
child: Column(
children: [
_Header(pt: pt, spaceId: activeSpaceId),
_FilterInput(
controller: _filterCtrl,
pt: pt,
onChanged: (v) => setState(() => _filter = v),
),
if (!isInvitedSpace)
_FilterInput(
controller: _filterCtrl,
pt: pt,
onChanged: (v) => setState(() => _filter = v),
),
Expanded(
child: isRealSpace
child: isInvitedSpace
? _SpaceInviteView(space: activeSpaceRoom!, pt: pt)
: isRealSpace
? _SpaceRoomsList(
spaceId: activeSpaceId,
filter: _filter,
@@ -111,21 +121,213 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
),
),
),
if (call.isActive && !call.isVoiceChannel) MiniCallWidget(call: call),
if (voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded)
MiniCallWidget(call: voip),
const UserPanel(),
// Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden.
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
// Konto-Balken nebeneinander statt übereinander.
Builder(builder: (context) {
final hasVoipCall = voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded;
final hasCallDock = call.isActive || hasVoipCall;
final isLandscapeCompact =
MediaQuery.sizeOf(context).height < 500;
final callDock = AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (call.isActive) MiniCallWidget(call: call),
if (hasVoipCall) MiniCallWidget(call: voip),
],
),
);
if (isLandscapeCompact && hasCallDock) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: callDock),
const Expanded(child: UserPanel()),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [callDock, const UserPanel()],
);
}),
],
),
);
}
}
// ─── Space invite view ─────────────────────────────────────────────────────────
class _SpaceInviteView extends ConsumerStatefulWidget {
final Room space;
final PyramidTheme pt;
const _SpaceInviteView({required this.space, required this.pt});
@override
ConsumerState<_SpaceInviteView> createState() => _SpaceInviteViewState();
}
class _SpaceInviteViewState extends ConsumerState<_SpaceInviteView> {
bool _accepting = false;
bool _declining = false;
Future<void> _accept() async {
setState(() => _accepting = true);
final client = widget.space.client;
final id = widget.space.id;
try {
await client.joinRoomById(id).timeout(const Duration(seconds: 20));
// Server confirmed the join. Switch to the channel list optimistically —
// the local sync membership may lag behind, so don't wait for it.
ref.read(forceJoinedSpacesProvider.notifier).add(id);
if (mounted) setState(() => _accepting = false);
} catch (e) {
if (mounted) {
setState(() => _accepting = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'),
backgroundColor: widget.pt.danger,
));
}
}
}
Future<void> _decline() async {
setState(() => _declining = true);
try {
await widget.space.leave();
if (mounted) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
}
} catch (_) {
if (mounted) setState(() => _declining = false);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final client = ref.watch(matrixClientProvider).valueOrNull;
final name = widget.space.getLocalizedDisplayname();
final busy = _accepting || _declining;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: pt.accent.withAlpha(40),
borderRadius: BorderRadius.circular(20),
),
child: client != null && widget.space.avatar != null
? ClipRRect(
borderRadius: BorderRadius.circular(20),
child: MxcAvatar(
mxcUri: widget.space.avatar,
client: client,
size: 72,
borderRadius: BorderRadius.circular(20),
placeholder: (_) => Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: pt.accent,
fontSize: 28,
fontWeight: FontWeight.w700),
),
),
),
)
: Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: pt.accent,
fontSize: 28,
fontWeight: FontWeight.w700),
),
),
),
const SizedBox(height: 16),
Text('Einladung zum Space',
style: TextStyle(color: pt.fgDim, fontSize: 13)),
const SizedBox(height: 4),
Text(
name,
textAlign: TextAlign.center,
style: TextStyle(
color: pt.fg, fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: pt.danger,
side: BorderSide(color: pt.danger.withAlpha(100)),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
onPressed: busy ? null : _decline,
child: _declining
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2))
: const Text('Ablehnen'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
onPressed: busy ? null : _accept,
child: _accepting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2))
: const Text('Beitreten'),
),
),
],
),
],
),
),
);
}
}
// ─── Header ───────────────────────────────────────────────────────────────────
enum _SpaceMenuItem {
newDm, createRoom, joinRoom, discoverRooms,
newDm, createGroup, createRoom, joinRoom, discoverRooms,
editSpace, adminSpace, leaveSpace,
}
@@ -159,6 +361,9 @@ class _Header extends ConsumerWidget {
switch (item) {
case _SpaceMenuItem.newDm:
showDialog(context: context, builder: (_) => const NewDmDialog());
case _SpaceMenuItem.createGroup:
showDialog(
context: context, builder: (_) => const CreateJoinDialog());
case _SpaceMenuItem.createRoom:
showDialog(
context: context, builder: (_) => const CreateJoinDialog());
@@ -237,6 +442,9 @@ class _Header extends ConsumerWidget {
value: _SpaceMenuItem.newDm,
child: _MenuRow(
Icons.chat_bubble_outline_rounded, 'Neue Unterhaltung', pt)),
PopupMenuItem(
value: _SpaceMenuItem.createGroup,
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
];
} else if (spaceId == 'rooms') {
return [
@@ -270,14 +478,17 @@ class _Header extends ConsumerWidget {
}
}
// Check for space banner — prefer local override (set immediately on save)
// over the state event (only available after next Matrix sync)
// Check for banner — space banner for real spaces, server banner otherwise.
final bannerOverrides = ref.watch(spaceBannerOverrideProvider);
final bannerUrl = isRealSpace && activeSpace != null
? (bannerOverrides.containsKey(activeSpace.id)
? bannerOverrides[activeSpace.id]
: activeSpace.getState(_kSpaceBannerType)?.content['url'] as String?)
: null;
final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {};
String? bannerUrl;
if (isRealSpace && activeSpace != null) {
bannerUrl = bannerOverrides.containsKey(activeSpace.id)
? bannerOverrides[activeSpace.id]
: activeSpace.getState(_kSpaceBannerType)?.content['url'] as String?;
} else if (client != null) {
bannerUrl = serverBanners[client.homeserver.toString()];
}
final hasBanner = bannerUrl != null && client != null;
Widget controlsRow = Row(
@@ -359,13 +570,9 @@ class _Header extends ConsumerWidget {
onPressed: () => handleMenu(_SpaceMenuItem.adminSpace),
),
if (!isRealSpace)
PyrIconBtn(
icon: const Icon(Icons.notifications_none_rounded),
tooltip: 'Notifications',
onPressed: () {},
),
_NotificationsButton(pt: pt, hasBanner: hasBanner),
PopupMenuButton<_SpaceMenuItem>(
tooltip: 'Optionen',
tooltip: 'Erstellen',
color: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
@@ -374,8 +581,8 @@ class _Header extends ConsumerWidget {
onSelected: handleMenu,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(Icons.keyboard_arrow_down_rounded,
color: hasBanner ? Colors.white70 : pt.fgDim, size: 16),
child: Icon(Icons.add_rounded,
color: hasBanner ? Colors.white70 : pt.fgDim, size: 18),
),
),
],
@@ -460,6 +667,122 @@ class _MenuRow extends StatelessWidget {
]);
}
// ─── Notifications button ──────────────────────────────────────────────────────
/// Glocke im Header: Popup mit allen Räumen, die ungelesene Nachrichten oder
/// Mentions haben. Tippen öffnet den jeweiligen Raum.
class _NotificationsButton extends ConsumerWidget {
final PyramidTheme pt;
final bool hasBanner;
const _NotificationsButton({required this.pt, required this.hasBanner});
@override
Widget build(BuildContext context, WidgetRef ref) {
final rooms = ref.watch(roomListProvider).valueOrNull ?? [];
final unread = rooms
.where((r) =>
r.membership == Membership.join &&
(r.notificationCount > 0 || r.highlightCount > 0))
.toList()
..sort((a, b) {
final hl = b.highlightCount.compareTo(a.highlightCount);
if (hl != 0) return hl;
return b.notificationCount.compareTo(a.notificationCount);
});
return PopupMenuButton<String>(
tooltip: 'Benachrichtigungen',
color: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: pt.border)),
offset: const Offset(0, 36),
onSelected: (roomId) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
},
itemBuilder: (_) {
if (unread.isEmpty) {
return [
PopupMenuItem<String>(
enabled: false,
child: Text('Keine neuen Benachrichtigungen',
style: TextStyle(color: pt.fgDim, fontSize: 13)),
),
];
}
return unread
.map((r) => PopupMenuItem<String>(
value: r.id,
child: Row(
children: [
Icon(
r.highlightCount > 0
? Icons.alternate_email_rounded
: (r.isDirectChat
? Icons.chat_bubble_outline_rounded
: Icons.tag_rounded),
size: 14,
color:
r.highlightCount > 0 ? pt.danger : pt.fgMuted,
),
const SizedBox(width: 8),
Expanded(
child: Text(
r.getLocalizedDisplayname(),
style: TextStyle(color: pt.fg, fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: r.highlightCount > 0 ? pt.danger : pt.accent,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${r.notificationCount > 0 ? r.notificationCount : r.highlightCount}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700),
),
),
],
),
))
.toList();
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Stack(
clipBehavior: Clip.none,
children: [
Icon(Icons.notifications_none_rounded,
size: 18, color: hasBanner ? Colors.white70 : pt.fgDim),
if (unread.isNotEmpty)
Positioned(
top: -1,
right: -1,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: pt.danger,
shape: BoxShape.circle,
border: Border.all(color: pt.bg1, width: 1.5),
),
),
),
],
),
),
);
}
}
// ─── Filter input ──────────────────────────────────────────────────────────────
class _FilterInput extends StatelessWidget {
@@ -534,6 +857,106 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
// Local order override after drag-and-drop (room IDs in order per category key)
final Map<String, List<String>> _localOrder = {};
// Joinable channels discovered via the space hierarchy (not yet a member).
List<_JoinableRoom> _joinable = [];
final Set<String> _joining = {};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final client = ref.read(matrixClientProvider).valueOrNull;
if (client != null) _loadHierarchy(client);
});
}
@override
void didUpdateWidget(covariant _SpaceRoomsList old) {
super.didUpdateWidget(old);
if (old.spaceId != widget.spaceId) {
setState(() => _joinable = []);
final client = ref.read(matrixClientProvider).valueOrNull;
if (client != null) _loadHierarchy(client);
}
}
/// Queries the server-side space hierarchy and keeps only rooms the user is
/// not yet a member of — these are the channels they can still join.
Future<void> _loadHierarchy(Client client) async {
try {
final resp = await client.getSpaceHierarchy(
widget.spaceId,
maxDepth: 3,
suggestedOnly: false,
);
final joinable = <_JoinableRoom>[];
for (final chunk in resp.rooms) {
if (chunk.roomId == widget.spaceId) continue;
if (chunk.roomType == 'm.space') continue; // skip sub-spaces
// Keep every channel the hierarchy reports. The build method filters out
// the ones already rendered in the joined sections — relying on local
// membership here is unreliable because Continuwuity's sync lags behind.
joinable.add(_JoinableRoom(
roomId: chunk.roomId,
name: chunk.name ?? chunk.canonicalAlias ?? chunk.roomId,
isVoice: chunk.roomType == _kVoiceRoomType,
memberCount: chunk.numJoinedMembers,
));
}
if (mounted) setState(() => _joinable = joinable);
} catch (_) {
// Hierarchy endpoint unavailable / no permission — silently ignore.
}
}
Future<void> _joinAndEnter(Client client, _JoinableRoom jr) async {
setState(() => _joining.add(jr.roomId));
try {
await client.joinRoomById(jr.roomId).timeout(const Duration(seconds: 20));
// Give the sync a brief chance to deliver the room object, but don't block
// on it — the local membership may lag behind the server.
Room? room = client.getRoomById(jr.roomId);
if (room == null) {
try {
await client.onSync.stream
.firstWhere((_) => client.getRoomById(jr.roomId) != null)
.timeout(const Duration(seconds: 4));
} catch (_) {}
room = client.getRoomById(jr.roomId);
}
if (!mounted) return;
setState(() => _joining.remove(jr.roomId));
if (jr.isVoice) {
// LiveKit only needs the room id + a display name, so connect even if the
// matrix room object hasn't synced locally yet.
final call = ref.read(callStateProvider);
if (call.isActive) await call.hangUp();
ref.read(activeVoiceRoomIdProvider.notifier).state = jr.roomId;
await call.startCall(
roomName: jr.roomId,
roomDisplayName: room?.getLocalizedDisplayname() ?? jr.name,
identity: client.userID ?? '',
audioOnly: true,
voiceChannel: true,
matrixClient: client,
matrixRoomId: jr.roomId,
);
} else if (room != null) {
ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
} catch (e) {
if (mounted) {
setState(() => _joining.remove(jr.roomId));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content:
Text('Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'),
backgroundColor: widget.pt.danger,
));
}
}
}
Future<void> _applyReorder(
Room space, String categoryKey, List<SpaceChild> newOrder) async {
try {
@@ -600,6 +1023,10 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
.where((r) => r.membership == Membership.invite)
.toList();
// IDs already rendered in the joined/invited sections — used to avoid showing
// them again under "available channels" from the hierarchy.
final shownIds = <String>{...invites.map((r) => r.id)};
void selectRoom(Room room) {
ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
@@ -643,6 +1070,7 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
.where((r) =>
r.membership != Membership.leave && !r.isSpace)
.toList();
shownIds.addAll(catChildren.map((r) => r.id));
final filtered = catChildren
.where((r) => widget.filter.isEmpty ||
@@ -677,6 +1105,7 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
.map((c) => client.getRoomById(c.roomId!))
.whereType<Room>()
.toList();
shownIds.addAll(directRooms.map((r) => r.id));
// Apply local order override if present
final localOrd = _localOrder['__direct__'];
@@ -698,6 +1127,14 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
.contains(widget.filter.toLowerCase()))
.toList();
// Joinable channels from the hierarchy that aren't already listed above.
final visibleJoinable = _joinable
.where((jr) =>
!shownIds.contains(jr.roomId) &&
(widget.filter.isEmpty ||
jr.name.toLowerCase().contains(widget.filter.toLowerCase())))
.toList();
// Build the complete list
return ListView(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
@@ -776,9 +1213,28 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
);
}),
],
// Joinable channels from the space hierarchy (not yet a member)
if (visibleJoinable.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 2),
child: Text('VERFÜGBARE KANÄLE',
style: TextStyle(
color: widget.pt.fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6)),
),
...visibleJoinable.map((jr) => _JoinableRoomItem(
jr: jr,
joining: _joining.contains(jr.roomId),
pt: widget.pt,
onJoin: () => _joinAndEnter(client, jr),
)),
],
if (filteredDirect.isEmpty &&
categoryWidgets.isEmpty &&
invites.isEmpty)
invites.isEmpty &&
visibleJoinable.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
@@ -791,6 +1247,110 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
}
}
// ─── Joinable room (from space hierarchy) ─────────────────────────────────────
class _JoinableRoom {
final String roomId;
final String name;
final bool isVoice;
final int memberCount;
const _JoinableRoom({
required this.roomId,
required this.name,
required this.isVoice,
required this.memberCount,
});
}
class _JoinableRoomItem extends StatefulWidget {
final _JoinableRoom jr;
final bool joining;
final PyramidTheme pt;
final VoidCallback onJoin;
const _JoinableRoomItem({
required this.jr,
required this.joining,
required this.pt,
required this.onJoin,
});
@override
State<_JoinableRoomItem> createState() => _JoinableRoomItemState();
}
class _JoinableRoomItemState extends State<_JoinableRoomItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final jr = widget.jr;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.joining ? null : widget.onJoin,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(vertical: 1),
padding: EdgeInsets.symmetric(
horizontal: pt.rowPadX, vertical: pt.rowPadY),
decoration: BoxDecoration(
color: _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: Icon(
jr.isVoice ? Icons.mic_rounded : Icons.tag_rounded,
size: 14,
color: pt.fgDim,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
jr.name,
style: TextStyle(
color: pt.fgMuted,
fontSize: 14,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
if (widget.joining)
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _hovered ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Beitreten',
style: TextStyle(
color: _hovered ? pt.accentFg : pt.fgMuted,
fontSize: 11,
fontWeight: FontWeight.w600),
),
),
],
),
),
),
);
}
}
// ─── Category header ───────────────────────────────────────────────────────────
class _CategoryHeader extends StatefulWidget {
@@ -1258,6 +1818,67 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
}
}
/// Touch-friendly action menu — opened via long-press (the hover menu is not
/// reachable on touch devices).
void _showContextMenu() {
final pt = widget.pt;
final room = widget.room;
final isDm = room.isDirectChat;
Widget action(IconData icon, String label, VoidCallback onTap,
{bool danger = false}) {
return ListTile(
leading: Icon(icon, size: 20, color: danger ? pt.danger : pt.fgMuted),
title: Text(label,
style: TextStyle(
color: danger ? pt.danger : pt.fg, fontSize: 14)),
onTap: () {
Navigator.pop(context);
onTap();
},
);
}
showModalBottomSheet(
context: context,
backgroundColor: pt.bg2,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(children: [
Expanded(
child: Text(room.getLocalizedDisplayname(),
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis),
),
]),
),
Divider(height: 1, color: pt.border),
if (!isDm) ...[
action(Icons.person_add_outlined, 'Nutzer einladen', _inviteUser),
action(Icons.settings_rounded, 'Einstellungen', _showRoomSettings),
],
action(
Icons.exit_to_app_rounded,
isDm ? 'Chat verlassen' : 'Raum verlassen',
isDm ? _leaveDm : _leaveRoom,
danger: true,
),
const SizedBox(height: 8),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
@@ -1279,6 +1900,7 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
onLongPress: _showContextMenu,
child: Stack(
children: [
if (active)
@@ -1693,7 +2315,15 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> {
Color? _dotColor() {
final p = _presence;
if (p == null) return null;
if (p.currentlyActive == true) return PyramidColors.online;
// Server-Presence kann veraltet sein (z.B. nie als offline gemeldet).
// Nur als online werten, wenn die letzte Aktivität wirklich frisch ist.
final last = p.lastActiveTimestamp;
final fresh = last != null &&
DateTime.now().difference(last) < const Duration(minutes: 5);
if (p.presence == PresenceType.online &&
(p.currentlyActive == true || fresh)) {
return PyramidColors.online;
}
if (p.presence == PresenceType.unavailable) return PyramidColors.away;
return null;
}
+16
View File
@@ -12,6 +12,22 @@ final roomListProvider = StreamProvider<List<Room>>((ref) async* {
}
});
final dmUnreadCountProvider = StreamProvider<int>((ref) async* {
final client = await ref.watch(matrixClientProvider.future);
yield _countDmUnread(client);
await for (final _ in client.onSync.stream) {
yield _countDmUnread(client);
}
});
int _countDmUnread(Client client) {
return client.rooms
.where((r) => !r.isSpace && r.isDirectChat && r.notificationCount > 0)
.length;
}
List<Room> _sortedRooms(Client client) {
final rooms = client.rooms
.where((r) => !r.isSpace)
+165
View File
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/pyramid_logo.dart';
final _ownProfileProvider = FutureProvider.autoDispose<Profile?>((ref) async {
final client = await ref.watch(matrixClientProvider.future);
final userId = client.userID;
if (userId == null) return null;
try {
return await client.getProfileFromUserId(userId);
} catch (_) {
return null;
}
});
class UserPanel extends ConsumerWidget {
const UserPanel({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final clientAsync = ref.watch(matrixClientProvider);
final profile = ref.watch(_ownProfileProvider).valueOrNull;
final client = clientAsync.valueOrNull;
final fallbackName = client?.userID?.split(':').first.replaceFirst('@', '') ?? '...';
final displayName = profile?.displayName ?? fallbackName;
final avatarUri = profile?.avatarUrl;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Container(
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: pt.border),
),
child: _ProfileRow(
pt: pt,
client: client,
avatarUri: avatarUri,
displayName: displayName,
ref: ref,
),
),
);
}
}
class _ProfileRow extends StatelessWidget {
final PyramidTheme pt;
final Client? client;
final Uri? avatarUri;
final String displayName;
final WidgetRef ref;
const _ProfileRow({
required this.pt,
required this.client,
required this.avatarUri,
required this.displayName,
required this.ref,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
Stack(
clipBehavior: Clip.none,
children: [
if (client != null && avatarUri != null)
MxcAvatar(
mxcUri: avatarUri!,
client: client!,
size: 32,
borderRadius: BorderRadius.circular(4),
placeholder: (_) => _InitialAvatar(name: displayName, radius: 4),
)
else
_InitialAvatar(name: displayName, radius: 4),
Positioned(
bottom: -2,
right: -2,
child: PresenceDot(status: 'online', size: 10, borderColor: pt.bg2),
),
],
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayName,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'online',
style: TextStyle(color: pt.fgMuted, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.settings_rounded),
tooltip: 'Einstellungen',
onPressed: () => ref.read(activeModalProvider.notifier).state = ModalKind.settings,
),
],
),
);
}
}
class _InitialAvatar extends StatelessWidget {
final String name;
final double radius;
final double size;
const _InitialAvatar({required this.name, required this.radius, this.size = 32});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFF6B9D), Color(0xFFC471F5)],
),
borderRadius: BorderRadius.circular(radius),
),
child: Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: Colors.white,
fontSize: size * 0.44,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+535
View File
@@ -0,0 +1,535 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart';
import 'package:pyramid/widgets/create_join_dialog.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/pyramid_logo.dart';
class SpacesRail extends ConsumerWidget {
const SpacesRail({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
final activeSpaceId = ref.watch(activeSpaceIdProvider);
final forceJoined = ref.watch(forceJoinedSpacesProvider);
final dmUnread = ref.watch(dmUnreadCountProvider).valueOrNull ?? 0;
void selectSpace(String? id) {
// Save current room for the space we're leaving
final currentRoom = ref.read(activeRoomIdProvider);
final currentSpace = ref.read(activeSpaceIdProvider);
if (currentRoom != null) {
final updated = Map<String?, String?>.from(ref.read(lastRoomPerSpaceProvider));
updated[currentSpace] = currentRoom;
ref.read(lastRoomPerSpaceProvider.notifier).state = updated;
}
// Switch space and restore last visited room for the new space
ref.read(activeSpaceIdProvider.notifier).state = id;
ref.read(activeRoomIdProvider.notifier).state =
ref.read(lastRoomPerSpaceProvider)[id];
final scaffold = Scaffold.maybeOf(context);
if (scaffold != null && scaffold.isDrawerOpen) {
scaffold.closeDrawer();
}
}
return Container(
width: 72,
color: pt.bg0,
child: Column(
children: [
const SizedBox(height: 12),
_HomeButton(
pt: pt,
active: activeSpaceId == 'dms' || activeSpaceId == null,
unreadCount: dmUnread,
onTap: () => selectSpace('dms'),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(
width: 32,
height: 2,
decoration: BoxDecoration(
color: pt.border,
borderRadius: BorderRadius.circular(2),
),
),
),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
_VirtualSpaceItem(
id: 'rooms',
label: 'R',
color: const Color(0xFFF59E0B),
name: 'Rooms',
active: activeSpaceId == 'rooms',
unread: 0,
pt: pt,
onTap: () => selectSpace('rooms'),
),
...spaces.map((space) => Padding(
padding: const EdgeInsets.only(top: 6),
child: _SpaceItem(
space: space,
active: activeSpaceId == space.id,
isInvite: space.membership == Membership.invite &&
!forceJoined.contains(space.id),
pt: pt,
onTap: () => selectSpace(space.id),
),
)),
const SizedBox(height: 8),
_AddButton(pt: pt),
const SizedBox(height: 8),
],
),
),
const SizedBox(height: 8),
_SettingsButton(pt: pt),
const SizedBox(height: 12),
],
),
);
}
}
class _SettingsButton extends ConsumerWidget {
final PyramidTheme pt;
const _SettingsButton({required this.pt});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: IconButton(
icon: Icon(Icons.settings_outlined, color: pt.fgDim),
onPressed: () {
ref.read(activeModalProvider.notifier).state = ModalKind.settings;
final scaffold = Scaffold.maybeOf(context);
if (scaffold != null && scaffold.isDrawerOpen) {
scaffold.closeDrawer();
}
},
),
);
}
}
class _HomeButton extends StatefulWidget {
final PyramidTheme pt;
final bool active;
final int unreadCount;
final VoidCallback onTap;
const _HomeButton({
required this.pt,
required this.active,
required this.unreadCount,
required this.onTap,
});
@override
State<_HomeButton> createState() => _HomeButtonState();
}
class _HomeButtonState extends State<_HomeButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Stack(
clipBehavior: Clip.none,
children: [
if (widget.active)
Positioned(
left: -12,
top: 10,
child: Container(
width: 4,
height: 28,
decoration: BoxDecoration(
color: pt.accent,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(3),
bottomRight: Radius.circular(3),
),
),
),
),
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutBack,
transform: _hovered
? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05))
: Matrix4.identity(),
child: Tooltip(
message: 'Direct Messages',
waitDuration: const Duration(milliseconds: 400),
preferBelow: false,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [pt.accent, Color.fromARGB(255,
(pt.accent.r * 255 * 0.85).round().clamp(0, 255),
(pt.accent.g * 255 * 0.85).round().clamp(0, 255),
(pt.accent.b * 255 * 0.7).round().clamp(0, 255),
)],
),
borderRadius: BorderRadius.circular(
widget.active ? pt.rLg * 0.6 : pt.rLg,
),
boxShadow: widget.active
? [BoxShadow(color: pt.accentGlow, blurRadius: 16, offset: const Offset(0, 4))]
: null,
),
child: Center(child: PyramidLogo(size: 28, color: Colors.white)),
),
if (widget.unreadCount > 0)
Positioned(
right: -4,
top: -4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
decoration: BoxDecoration(
color: pt.danger,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: pt.bg0, width: 1.5),
),
child: Center(
child: Text(
widget.unreadCount > 99 ? '99+' : '${widget.unreadCount}',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w700),
),
),
),
),
],
),
),
),
),
),
],
);
}
}
class _VirtualSpaceItem extends StatefulWidget {
final String id;
final String label;
final Color color;
final String name;
final bool active;
final int unread;
final PyramidTheme pt;
final VoidCallback onTap;
const _VirtualSpaceItem({
required this.id,
required this.label,
required this.color,
required this.name,
required this.active,
required this.unread,
required this.pt,
required this.onTap,
});
@override
State<_VirtualSpaceItem> createState() => _VirtualSpaceItemState();
}
class _VirtualSpaceItemState extends State<_VirtualSpaceItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final active = widget.active;
final radius = active
? pt.rLg * 0.6
: _hovered ? pt.rLg * 0.7 : pt.rLg;
return Center(
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Stack(
clipBehavior: Clip.none,
children: [
// Active indicator
if (active)
Positioned(
left: -18,
top: 10,
child: Container(
width: 4,
height: 28,
decoration: BoxDecoration(
color: pt.accent,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(3),
bottomRight: Radius.circular(3),
),
),
),
),
Tooltip(
message: widget.name,
waitDuration: const Duration(milliseconds: 400),
preferBelow: false,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutBack,
width: 48,
height: 48,
decoration: BoxDecoration(
color: _hovered ? pt.bg3 : pt.bg2,
borderRadius: BorderRadius.circular(radius),
),
child: Center(
child: PyramidGlyph(
size: 30,
color: widget.color,
label: widget.label,
),
),
),
),
],
),
),
),
);
}
}
class _SpaceItem extends ConsumerStatefulWidget {
final Room space;
final bool active;
final bool isInvite;
final PyramidTheme pt;
final VoidCallback onTap;
const _SpaceItem({
required this.space,
required this.active,
this.isInvite = false,
required this.pt,
required this.onTap,
});
@override
ConsumerState<_SpaceItem> createState() => _SpaceItemState();
}
class _SpaceItemState extends ConsumerState<_SpaceItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final active = widget.active;
final name = widget.space.getLocalizedDisplayname();
final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
final radius = active ? pt.rLg * 0.6 : _hovered ? pt.rLg * 0.7 : pt.rLg;
final client = ref.watch(matrixClientProvider).valueOrNull;
final avatarUri = widget.space.avatar;
Widget avatarChild;
if (client != null && avatarUri != null) {
avatarChild = MxcAvatar(
mxcUri: avatarUri,
client: client,
size: 48,
borderRadius: BorderRadius.circular(radius),
placeholder: (_) => _SpaceInitial(letter: letter, pt: pt, radius: radius),
);
} else {
avatarChild = _SpaceInitial(letter: letter, pt: pt, radius: radius);
}
return Center(
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
onSecondaryTap: widget.isInvite
? null
: () => showDialog(
context: context,
builder: (_) => SpaceEditDialog(space: widget.space),
),
onLongPress: widget.isInvite
? null
: () => showDialog(
context: context,
builder: (_) => SpaceEditDialog(space: widget.space),
),
child: Stack(
clipBehavior: Clip.none,
children: [
if (active)
Positioned(
left: -18,
top: 10,
child: Container(
width: 4,
height: 28,
decoration: BoxDecoration(
color: pt.accent,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(3),
bottomRight: Radius.circular(3),
),
),
),
),
Tooltip(
message: name,
waitDuration: const Duration(milliseconds: 400),
preferBelow: false,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutBack,
width: 48,
height: 48,
decoration: BoxDecoration(
color: _hovered ? pt.bg3 : pt.bg2,
borderRadius: BorderRadius.circular(radius),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: avatarChild,
),
),
),
if (widget.isInvite)
Positioned(
right: -2,
top: -2,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: pt.accent,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: pt.bg0, width: 1.5),
),
child: const Text(
'NEU',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.w800,
letterSpacing: 0.3),
),
),
),
],
),
),
),
);
}
}
class _SpaceInitial extends StatelessWidget {
final String letter;
final PyramidTheme pt;
final double radius;
const _SpaceInitial({required this.letter, required this.pt, required this.radius});
@override
Widget build(BuildContext context) {
return Center(
child: Text(letter, style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
);
}
}
class _AddButton extends StatefulWidget {
final PyramidTheme pt;
const _AddButton({required this.pt});
@override
State<_AddButton> createState() => _AddButtonState();
}
class _AddButtonState extends State<_AddButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Center(
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: () => showDialog(
context: context,
builder: (_) => const CreateJoinDialog(),
),
child: Tooltip(
message: 'Create · Join · Discover',
waitDuration: const Duration(milliseconds: 400),
preferBelow: false,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutBack,
width: 48,
height: 48,
decoration: BoxDecoration(
color: _hovered ? pt.accentSoft : pt.bg2,
borderRadius: BorderRadius.circular(_hovered ? pt.rLg * 0.7 : pt.rLg),
border: Border.all(
color: _hovered ? pt.accent : pt.borderStrong,
width: 1.5,
style: BorderStyle.solid,
),
),
child: Center(
child: AnimatedRotation(
turns: _hovered ? 0.25 : 0,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutBack,
child: Icon(Icons.add, color: pt.accent, size: 22),
),
),
),
),
),
),
);
}
}