diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d5a283a..2412157 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md new file mode 100644 index 0000000..c80469e --- /dev/null +++ b/docs/feature-checklist.md @@ -0,0 +1,354 @@ +# Matrix / Element Feature Checklist für Pyramid + +*Stand: April 2026 — Pyramid v0.1.x (Early Development)* + +--- + +## Nachrichten (Messaging) + +### Kern-Funktionen +- [x] Textnachrichten senden +- [x] Nachrichten empfangen & anzeigen +- [x] Zeitstempel (formatiert, relativ) +- [x] Nachrichtengruppierung (gleicher Sender, <5 Min.) +- [x] Datumstrennlinien zwischen Gruppen +- [x] Verschlüsselte Nachrichten anzeigen (Entschlüsselungsstatus) +- [x] Nachrichtenverlauf paginieren (ältere Nachrichten beim Scrollen laden) +- [x] Ungelesene-Nachrichten-Tracking + +### Nachrichtenaktionen +- [x] Nachrichtentext kopieren +- [x] Eigene Nachrichten löschen (redact) +- [x] Nachrichten anpinnen / Pinboard anzeigen +- [~] Auf Nachrichten antworten (in-reply-to vorhanden, kein visuelles Zitatfeld) +- [~] Nachrichten bearbeiten (m.edit im SDK erkannt, kein vollständiges UI) +- [ ] Nachrichten weiterleiten +- [ ] Zitat-Styling (visuelle Blockquote-Anzeige) + +### Textformatierung +- [ ] Fett (**text**) +- [ ] Kursiv (*text*) +- [ ] Durchgestrichen (~~text~~) +- [ ] Inline-Code (`code`) +- [ ] Code-Block (```code```) +- [ ] Aufzählungslisten +- [ ] Klickbare Links +- [ ] @Erwähnungen (User) +- [ ] #Kanal-Erwähnungen +- [ ] Spoiler-Tags (||text||) + +### Medien & Anhänge +- [x] Dateianhänge senden (mit MIME-Erkennung) +- [x] Bilder senden & inline anzeigen +- [x] Videos senden & inline abspielen +- [x] Audio senden & inline abspielen +- [x] Sticker anzeigen (m.sticker) +- [x] Dateianhang mit Untertitel +- [x] Dateigröße berechnen & anzeigen +- [x] Verschlüsselte Medien entschlüsseln & herunterladen +- [x] Thumbnail-Generierung für Bilder/Videos +- [~] Sprachnachrichten (Struktur vorhanden, Wiedergabe-UI fehlt) +- [ ] Animierte GIFs +- [ ] Link-Vorschau (URL-Unfurling) +- [ ] Dokument-Vorschau (PDF, Office) + +### Reaktionen +- [x] Emoji-Reaktionen senden +- [x] Reaktionszähler anzeigen +- [x] 6 häufigste Reaktionen als Schnellauswahl +- [x] Vollständiger Emoji-Picker mit Suche +- [x] Eigene Reaktion entfernen +- [x] Anzeigen wer reagiert hat +- [x] Mehrere Reaktionen pro Nachricht +- [x] Kategoriebasierte Emoji-Organisation + +### Threads +- [ ] Thread-Antworten (m.in_reply_to vorhanden, kein Thread-View) +- [ ] Thread aufklappen / eigene Ansicht +- [ ] Thread-Benachrichtigungen +- [ ] Thread als gelöst markieren + +--- + +## Raumverwaltung + +### Grundfunktionen +- [x] Raumliste anzeigen (sortiert nach letzter Aktivität) +- [x] Räume beitreten +- [x] Räume verlassen +- [x] Raumname & Avatar anzeigen +- [x] Verschlüsselungsstatus (Schloss-Icon) +- [x] Einladungen annehmen / ablehnen +- [ ] Neuen Raum erstellen +- [ ] Raumname bearbeiten +- [ ] Raumthema/Beschreibung bearbeiten +- [ ] Raum-Avatar ändern +- [ ] Raumvisibilität (öffentlich / privat) +- [ ] Zugriffsregeln (invite/knock/public) +- [ ] Verlaufsvisibilität konfigurieren +- [ ] Raum-Aliases verwalten + +### Direktnachrichten (DMs) +- [x] 1:1 DM-Räume anzeigen & öffnen +- [x] Presence-Status des Gegenübers anzeigen +- [x] Echtzeit-Presence-Updates +- [x] Sprachanruf aus DM starten +- [x] Videoanruf aus DM starten +- [ ] DM starten (neuen Chat beginnen, nur über Suchfeld) + +### Mitglieder & Rollen +- [x] Mitgliederliste anzeigen +- [x] Mitgliederanzahl anzeigen +- [x] Avatar & Anzeigename der Mitglieder +- [x] Nutzer in Raum einladen (Raum-Kontextmenü) +- [ ] Nutzer aus Raum entfernen (Kick) +- [ ] Nutzer sperren (Ban) +- [ ] Power Levels bearbeiten +- [ ] Nutzer zum Moderator / Admin befördern +- [ ] Bans aufheben + +### Raumeinstellungen +- [x] Raumeinstellungen-Dialog (Name, Thema, Moderationsregeln) +- [ ] Raumverschlüsselung aktivieren/deaktivieren +- [ ] Erweiterte Raumeinstellungen (Fluffychat-Level) +- [ ] Widget-/App-Integration in Räume + +--- + +## Spaces & Gemeinschaften + +### Spaces +- [x] Spaces in der Seitenleiste anzeigen +- [x] Zwischen Spaces navigieren +- [x] Space-Avatar anzeigen +- [x] Space-Banner hochladen & anzeigen (Discord-Style) +- [x] Räume nach Space filtern +- [x] Virtuelle "Rooms"- und "DMs"-Spaces +- [x] Unterkategorien (Sub-Spaces) als Kategorien in der Kanalleiste +- [x] Drag-and-drop Kanalreihenfolge für Admins +- [x] Space-Verwaltungsdialog (Name, Avatar, Banner, Einladungen, Mitglieder, Power Levels) +- [ ] Neuen Space erstellen +- [ ] Räume zum Space hinzufügen / entfernen +- [ ] Space-Regeln / Beschreibung +- [ ] Space-Entdeckung (öffentliche Spaces durchsuchen) + +### Kanalorganisation +- [x] Kanäle nach letzter Aktivität sortieren +- [x] Ungelesene-Badge auf Kanälen +- [x] Sprachkanal-Teilnehmerliste (Discord-Style, mit Echtzeit-Updates) +- [x] Kollabierbare Kategorien +- [ ] Favoriten / angeheftete Räume +- [ ] Räume stummschalten +- [ ] Räume ausblenden / archivieren +- [ ] Kanalreihenfolge für reguläre Nutzer + +--- + +## Sprach- & Videoanrufe + +### Sprachanrufe (LiveKit) +- [x] Sprachkanal betreten / verlassen (Toggle) +- [x] Teilnehmerliste im Sprachkanal +- [x] Mikrofon stummschalten +- [x] Audio-Ausgabe deaktivieren (Deafen) +- [x] Anruf-UI im Profil-Panel (aktiver Anruf) +- [x] Anruf-Ansicht öffnen (Vollansicht) +- [x] Mini-Call-Widget bei VOIP-Anrufen +- [ ] Klingelton bei eingehenden Anrufen +- [ ] Anruf annehmen / ablehnen (UI) +- [ ] Anrufdauer-Timer +- [ ] Anrufverlauf + +### Videoanrufe +- [x] Videoanruf starten (aus DM) +- [x] Kamera-Toggle +- [x] Videoteilnehmer-Anzeige +- [ ] Bildschirmfreigabe (Toggle-UI vorhanden, Implementierung unklar) +- [ ] Picture-in-Picture-Modus +- [ ] Virtueller Hintergrund + +### Gruppenanrufe +- [x] Mehrere Teilnehmer im Sprachkanal (LiveKit) +- [ ] Gruppen-Videogitter +- [ ] Spotlight-/Galerie-Ansicht + +--- + +## Benachrichtigungen & Presence + +### Push-Benachrichtigungen +- [ ] Desktop-Benachrichtigungen (Windows/macOS/Linux) +- [ ] Mobile Push-Benachrichtigungen (Android/iOS) +- [ ] Benachrichtigungseinstellungen pro Raum +- [ ] Nicht-Stören-Modus +- [ ] Benachrichtigungssound konfigurieren + +### Presence & Status +- [x] Nutzer-Presence abrufen (online/offline/abwesend) +- [x] Presence-Indikator anzeigen +- [x] Letzte-Aktivität-Zeitstempel +- [x] Echtzeit-Presence-Updates +- [ ] Eigenen Status setzen (online/abwesend/unsichtbar) +- [ ] Benutzerdefinierte Statusmeldung +- [ ] Aktivitätsbasierte Status-Änderung + +### Tipp-Indikatoren +- [ ] "X tippt gerade"-Anzeige empfangen +- [ ] Eigene Tipp-Benachrichtigung senden +- [ ] Timeout nach Tipp-Ende + +--- + +## Nutzereinstellungen & Profil + +### Profilverwaltung +- [x] Eigene User-ID anzeigen +- [x] Eigenen Avatar anzeigen (Initialen-Fallback) +- [x] Anzeigenamen anzeigen +- [ ] Anzeigenamen bearbeiten +- [ ] Eigenen Avatar hochladen / entfernen +- [ ] Status-Nachricht setzen + +### Kontosicherheit +- [ ] Passwort ändern +- [ ] E-Mail-Adresse verwalten +- [ ] Sitzungsverwaltung (alle Sitzungen anzeigen) +- [ ] Andere Sitzungen abmelden +- [ ] Konto deaktivieren / löschen +- [ ] Zwei-Faktor-Authentifizierung (2FA) + +### App-Einstellungen +- [x] Hell / Dunkel-Theme +- [x] Akzentfarbe (6 Voreinstellungen) +- [x] Eckradius anpassen +- [x] UI-Dichte / Kompaktheit +- [x] Animationssteuerung +- [ ] Schriftgröße anpassen +- [ ] Sprachauswahl (i18n) +- [ ] Kompaktansicht (kein Avatar in Nachrichten) + +--- + +## Datenschutz & Sicherheit + +### Ende-zu-Ende-Verschlüsselung (E2EE) +- [x] E2EE-Unterstützung (Olm/Megolm via vodozemac) +- [x] E2EE-Schlüssel beim ersten Login einrichten (Bootstrap) +- [x] Verschlüsselte Nachrichten-Indikatoren +- [x] Entschlüsselungsstatus anzeigen +- [x] SSSS (Symmetric Secret Storage) +- [x] Recovery-Key erzeugen & anzeigen +- [x] Recovery-Key aus bestehender Einrichtung importieren +- [x] Schlüssel-Caching (lokal speichern) +- [x] Session-Keys von anderen Geräten anfordern +- [ ] Schlüssel-Backup (Server-seitig) +- [ ] Schlüssel-Backup verifizieren + +### Geräte-Verifizierung +- [x] Cross-Signing (Master/Self-Signing/User-Signing Keys) +- [x] Gerät per Emoji verifizieren +- [x] Gerät per Zahlencodes verifizieren +- [x] Geräte als vertrauenswürdig markieren +- [ ] Gerätename / Modell anzeigen +- [ ] Sitzungsverwaltung (alle Geräte dieses Accounts) +- [ ] Fremde Geräte abmelden + +### Vertrauen & Sicherheit +- [ ] Geräte-Vertrauensstatus-Indikator in Nachrichten +- [ ] Nutzer blockieren / entsperren +- [ ] Blockierte Nutzer-Liste +- [ ] Verifizierter-Nutzer-Badge + +--- + +## Suche + +### Nachrichtensuche +- [x] Suchdialog öffnen (UI vorhanden) +- [ ] Volltextsuche in Nachrichteninhalten +- [ ] Filter nach Absender +- [ ] Filter nach Datum +- [ ] Filter nach Raum +- [ ] Filter nach Medientyp +- [ ] Suchergebnisse hervorheben +- [ ] Suchverlauf + +### Nutzer- / Raumsuche +- [ ] Nutzer nach Anzeigenamen suchen +- [ ] Nutzer nach User-ID suchen +- [ ] Öffentliche Räume entdecken (Directory) +- [ ] Eigene Räume filtern +- [ ] Räume nach Name / Thema suchen + +--- + +## Auto-Update + +- [x] GitHub-Releases als Update-Quelle +- [x] Versions-Vergleich (SemVer) +- [x] Rate-Limiting (max. alle 4 Stunden prüfen) +- [x] Update-Banner im App-Header (Windows) +- [x] "Diese Version überspringen"-Option +- [x] Manueller Update-Check in den Einstellungen +- [ ] Automatischer Download & Installation (Squirrel/NSIS) +- [ ] Delta-Updates (nur geänderte Dateien) +- [ ] Update-Kanal wählen (Stable/Beta/Nightly) + +--- + +## Plattform-spezifisch + +### Desktop (Windows / macOS / Linux) +- [x] Native Desktop-App (Flutter Desktop) +- [x] Tastenkürzel (Enter = Senden, Shift+Enter = Zeile) +- [x] Drag-and-Drop für Dateianhänge +- [ ] System-Tray-Integration +- [ ] Desktop-Benachrichtigungen +- [ ] Minimieren in System-Tray +- [ ] Fenster-State speichern (Größe, Position) +- [ ] MSIX / Installer-Paketierung + +### Mobile (Android / iOS) +- [x] Mobile App (Flutter) +- [x] Drawer-Navigation (Hamburger-Menü) +- [ ] Touch-optimiertes UI (derzeit Desktop-first) +- [ ] Mobile Push-Benachrichtigungen +- [ ] Zurück-Geste Navigation +- [ ] Bottom-Sheet-Modals +- [ ] Share-Sheet-Integration +- [ ] Haptisches Feedback + +--- + +## Statistik + +| Bereich | Implementiert | Teilweise | Fehlend | +|---|---|---|---| +| Messaging | 14 | 2 | 14 | +| Raumverwaltung | 8 | 0 | 14 | +| Spaces | 10 | 0 | 6 | +| Anrufe | 8 | 1 | 10 | +| Benachrichtigungen | 4 | 0 | 8 | +| Einstellungen/Profil | 5 | 0 | 11 | +| Sicherheit/E2EE | 9 | 0 | 7 | +| Suche | 1 | 0 | 10 | +| Auto-Update | 6 | 0 | 3 | +| Plattform | 5 | 0 | 10 | +| **Gesamt** | **~70** | **~3** | **~93** | + +**Implementierungsgrad: ~42 %** (inkl. Teilimplementierungen) + +--- + +## Empfohlene Feature-Prioritäten + +1. **Textformatierung** — häufig genutzt, von Nutzern erwartet +2. **Push-Benachrichtigungen** — Desktop-Benachrichtigungen für Windows +3. **Tipp-Indikatoren** — verbessert Gesprächsgefühl deutlich +4. **Nachrichtenbearbeitung** — SDK-Grundlage vorhanden, nur UI fehlt +5. **Profilbearbeitung** — Avatar & Anzeigename ändern +6. **Raum erstellen** — Basis-Workflow, derzeit nicht möglich +7. **Sprachnachricht abspielen** — Struktur vorhanden +8. **Suche** — Dialog vorhanden, Backend-Abfrage fehlt +9. **Nutzer blockieren** — Sicherheits-Grundfunktion +10. **System-Tray** — Desktop-Erlebnis verbessern diff --git a/lib/core/update_checker.dart b/lib/core/update_checker.dart new file mode 100644 index 0000000..f48ac27 --- /dev/null +++ b/lib/core/update_checker.dart @@ -0,0 +1,156 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// ─── Configuration ──────────────────────────────────────────────────────────── + +const _kGiteaBase = 'https://git.steggi-matrix.work'; +const _kGiteaRepo = 'steggi/pyramid'; +const _kCheckIntervalHours = 4; +const _kPrefLastCheck = 'update_last_check_ms'; +const _kPrefSkippedTag = 'update_skipped_tag'; + +// ─── Data model ─────────────────────────────────────────────────────────────── + +class UpdateInfo { + final String tagName; + final String releaseName; + final String body; + final String htmlUrl; + final String? downloadUrl; + final (int, int, int) remoteVersion; + + const UpdateInfo({ + required this.tagName, + required this.releaseName, + required this.body, + required this.htmlUrl, + required this.remoteVersion, + this.downloadUrl, + }); + + bool get canDownload => downloadUrl != null; +} + +// ─── Version helpers ────────────────────────────────────────────────────────── + +(int, int, int)? _parseVersion(String raw) { + final clean = raw.trim().replaceFirst(RegExp(r'^v'), '').split('+').first; + final parts = clean.split('.'); + if (parts.length < 3) return null; + try { + return (int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2])); + } catch (_) { + return null; + } +} + +bool _isNewer((int, int, int) remote, (int, int, int) local) { + if (remote.$1 != local.$1) return remote.$1 > local.$1; + if (remote.$2 != local.$2) return remote.$2 > local.$2; + return remote.$3 > local.$3; +} + +String? _findDownloadUrl(List assets) { + if (assets.isEmpty) return null; + final list = assets.cast>(); + + String? pick(bool Function(String name) test) { + for (final a in list) { + final name = (a['name'] as String? ?? '').toLowerCase(); + if (test(name)) return a['browser_download_url'] as String?; + } + return null; + } + + if (Platform.isWindows) { + return pick((n) => n.endsWith('.exe')) ?? pick((n) => n.endsWith('.msix')); + } + if (Platform.isAndroid) { + return pick((n) => n.endsWith('.apk')); + } + return null; +} + +// ─── Fetch ──────────────────────────────────────────────────────────────────── + +Future _fetchLatestRelease() async { + try { + final uri = Uri.parse( + '$_kGiteaBase/api/v1/repos/$_kGiteaRepo/releases?limit=1', + ); + final response = await http + .get(uri, headers: {'Accept': 'application/json'}) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) return null; + + final list = json.decode(response.body) as List; + if (list.isEmpty) return null; + final data = list.first as Map; + + final tagName = data['tag_name'] as String? ?? ''; + if (tagName.isEmpty) return null; + + final remoteVer = _parseVersion(tagName); + if (remoteVer == null) return null; + + final assets = (data['assets'] as List?) ?? []; + final downloadUrl = _findDownloadUrl(assets); + + return UpdateInfo( + tagName: tagName, + releaseName: data['name'] as String? ?? tagName, + body: data['body'] as String? ?? '', + htmlUrl: '$_kGiteaBase/$_kGiteaRepo/releases/tag/$tagName', + remoteVersion: remoteVer, + downloadUrl: downloadUrl, + ); + } catch (_) { + return null; + } +} + +// ─── Provider ───────────────────────────────────────────────────────────────── + +final updateInfoProvider = FutureProvider((ref) async { + // iOS: can't sideload, skip + if (Platform.isIOS || Platform.isMacOS || Platform.isLinux) return null; + + final prefs = await SharedPreferences.getInstance(); + final lastCheckMs = prefs.getInt(_kPrefLastCheck) ?? 0; + final elapsed = DateTime.now().millisecondsSinceEpoch - lastCheckMs; + if (elapsed < _kCheckIntervalHours * 3600 * 1000) return null; + + await prefs.setInt(_kPrefLastCheck, DateTime.now().millisecondsSinceEpoch); + + final remote = await _fetchLatestRelease(); + if (remote == null) return null; + + final info = await PackageInfo.fromPlatform(); + final localVer = _parseVersion(info.version); + if (localVer == null) return null; + + if (!_isNewer(remote.remoteVersion, localVer)) return null; + + final skipped = prefs.getString(_kPrefSkippedTag); + if (skipped == remote.tagName) return null; + + return remote; +}); + +// ─── Actions ────────────────────────────────────────────────────────────────── + +Future skipUpdateVersion(String tagName) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kPrefSkippedTag, tagName); +} + +Future resetUpdateCheckTimer() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kPrefLastCheck); +} diff --git a/lib/features/chat/chat_input.dart b/lib/features/chat/chat_input.dart index e5a9d11..f5230df 100644 --- a/lib/features/chat/chat_input.dart +++ b/lib/features/chat/chat_input.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:matrix/matrix.dart'; @@ -53,6 +54,10 @@ class _ChatInputState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isDesktop = !kIsWeb && + (defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.macOS); return SafeArea( child: Column( @@ -63,7 +68,12 @@ class _ChatInputState extends State { onClear: widget.onClearReply ?? () {}, ), Padding( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + padding: EdgeInsets.fromLTRB( + isDesktop ? 12 : 8, + isDesktop ? 10 : 4, + isDesktop ? 12 : 8, + isDesktop ? 14 : 8, + ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -73,14 +83,19 @@ class _ChatInputState extends State { minLines: 1, maxLines: 6, textCapitalization: TextCapitalization.sentences, + style: TextStyle(fontSize: isDesktop ? 16 : 14), decoration: InputDecoration( hintText: 'Nachricht...', - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, + hintStyle: TextStyle( + fontSize: isDesktop ? 16 : 14, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + contentPadding: EdgeInsets.symmetric( + horizontal: isDesktop ? 22 : 16, + vertical: isDesktop ? 20 : 10, ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(isDesktop ? 16 : 24), borderSide: BorderSide.none, ), filled: true, @@ -89,19 +104,30 @@ class _ChatInputState extends State { onSubmitted: (_) => _send(), keyboardType: TextInputType.multiline, inputFormatters: [ - // Shift+Enter = newline, Enter = send _SendOnEnterFormatter(onSend: _send), ], ), ), - const SizedBox(width: 8), - AnimatedScale( - scale: _canSend ? 1.0 : 0.8, - duration: const Duration(milliseconds: 150), - child: FloatingActionButton.small( - onPressed: _canSend ? _send : null, - elevation: _canSend ? 2 : 0, - child: const Icon(Icons.send_rounded), + SizedBox(width: isDesktop ? 10 : 8), + GestureDetector( + onTap: _canSend ? _send : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: isDesktop ? 60 : 44, + height: isDesktop ? 60 : 44, + decoration: BoxDecoration( + color: _canSend + ? theme.colorScheme.primary + : theme.colorScheme.primary.withAlpha(70), + borderRadius: BorderRadius.circular(isDesktop ? 16 : 14), + ), + child: Center( + child: Icon( + Icons.send_rounded, + size: isDesktop ? 26 : 20, + color: Colors.white, + ), + ), ), ), ], diff --git a/lib/features/rooms/rooms_panel.dart b/lib/features/rooms/rooms_panel.dart new file mode 100644 index 0000000..1b9d89b --- /dev/null +++ b/lib/features/rooms/rooms_panel.dart @@ -0,0 +1,1924 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' hide Visibility; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:matrix/src/utils/space_child.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/features/call/mini_call_widget.dart'; +import 'package:pyramid/features/rooms/user_panel.dart'; +import 'package:pyramid/features/spaces/space_admin_dialog.dart'; +import 'package:pyramid/widgets/create_join_dialog.dart'; +import 'package:pyramid/widgets/hover_region.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; +import 'package:pyramid/widgets/new_dm_dialog.dart'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const _kVoiceRoomType = 'io.pyramid.voice_channel'; +const _kSpaceBannerType = 'io.pyramid.space.banner'; + +bool _isVoiceRoom(Room room) => + room.getState(EventTypes.RoomCreate)?.content['type'] == _kVoiceRoomType; + +int _compareSpaceChildren(SpaceChild a, SpaceChild b) { + final ao = a.order; + final bo = b.order; + if (ao.isEmpty && bo.isEmpty) return 0; + if (ao.isEmpty) return 1; + if (bo.isEmpty) return -1; + return ao.compareTo(bo); +} + +// ─── Panel shell ────────────────────────────────────────────────────────────── + +class RoomsPanel extends ConsumerStatefulWidget { + const RoomsPanel({super.key}); + + @override + ConsumerState createState() => _RoomsPanelState(); +} + +class _RoomsPanelState extends ConsumerState { + final _filterCtrl = TextEditingController(); + String _filter = ''; + final Map _collapsed = {}; + + @override + void dispose() { + _filterCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final roomsAsync = ref.watch(roomListProvider); + final activeSpaceId = ref.watch(activeSpaceIdProvider); + final call = ref.watch(callStateProvider); + final voip = ref.watch(voipStateProvider); + + final isRealSpace = activeSpaceId != null && + activeSpaceId != 'dms' && + activeSpaceId != 'rooms'; + + return Container( + color: pt.bg1, + child: Column( + children: [ + _Header(pt: pt, spaceId: activeSpaceId), + _FilterInput( + controller: _filterCtrl, + pt: pt, + onChanged: (v) => setState(() => _filter = v), + ), + Expanded( + child: isRealSpace + ? _SpaceRoomsList( + spaceId: activeSpaceId, + filter: _filter, + collapsed: _collapsed, + pt: pt, + onToggleSection: (id) => setState(() { + _collapsed[id] = !(_collapsed[id] ?? false); + }), + ) + : roomsAsync.when( + loading: () => Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: pt.accent), + ), + ), + error: (e, _) => Center( + child: Text('Error', + style: TextStyle(color: pt.fgDim, fontSize: 12)), + ), + data: (rooms) => _RoomsList( + rooms: rooms, + filter: _filter, + spaceId: activeSpaceId, + collapsed: _collapsed, + pt: pt, + onToggleSection: (id) => setState(() { + _collapsed[id] = !(_collapsed[id] ?? false); + }), + ), + ), + ), + if (call.isActive && !call.isVoiceChannel) MiniCallWidget(call: call), + if (voip.currentCall != null && + voip.currentCall!.state != CallState.kEnded) + MiniCallWidget(call: voip), + const UserPanel(), + ], + ), + ); + } +} + +// ─── Header ─────────────────────────────────────────────────────────────────── + +enum _SpaceMenuItem { + newDm, createRoom, joinRoom, discoverRooms, + editSpace, adminSpace, leaveSpace, +} + +class _Header extends ConsumerWidget { + final PyramidTheme pt; + final String? spaceId; + const _Header({required this.pt, required this.spaceId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms'; + final size = MediaQuery.sizeOf(context); + final isMobile = size.width < 900 || size.height < 500; + + final spaces = ref.watch(spacesProvider).valueOrNull ?? []; + Room? activeSpace; + if (isRealSpace) { + try { + activeSpace = spaces.firstWhere((s) => s.id == spaceId); + } catch (_) {} + } + final client = ref.watch(matrixClientProvider).valueOrNull; + final isAdmin = + activeSpace != null && activeSpace.ownPowerLevel >= 50; + + final name = isRealSpace + ? (activeSpace?.getLocalizedDisplayname() ?? 'Space') + : (spaceId == 'dms' ? 'Chats' : 'Rooms'); + + void handleMenu(_SpaceMenuItem item) { + switch (item) { + case _SpaceMenuItem.newDm: + showDialog(context: context, builder: (_) => const NewDmDialog()); + case _SpaceMenuItem.createRoom: + showDialog( + context: context, builder: (_) => const CreateJoinDialog()); + case _SpaceMenuItem.joinRoom: + showDialog( + context: context, + builder: (_) => + const CreateJoinDialog(initialTab: CreateJoinTab.join)); + case _SpaceMenuItem.discoverRooms: + showDialog( + context: context, + builder: (_) => + const CreateJoinDialog(initialTab: CreateJoinTab.discover)); + case _SpaceMenuItem.editSpace: + if (activeSpace != null) { + showDialog( + context: context, + builder: (_) => SpaceEditDialog(space: activeSpace!)); + } + case _SpaceMenuItem.adminSpace: + if (activeSpace != null) { + showDialog( + context: context, + builder: (_) => SpaceAdminDialog(space: activeSpace!)); + } + case _SpaceMenuItem.leaveSpace: + if (activeSpace != null) { + final space = activeSpace; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + title: Text('Space verlassen?', + style: TextStyle( + color: pt.fg, + fontSize: 16, + fontWeight: FontWeight.w700)), + content: Text( + 'Du verlässt "${space.getLocalizedDisplayname()}".', + style: TextStyle(color: pt.fgMuted, fontSize: 13)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('Abbrechen', + style: TextStyle(color: pt.fgMuted))), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.danger, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Verlassen'), + ), + ], + ), + ).then((confirmed) { + if (confirmed == true) { + space.leave().then((_) { + ref.read(activeSpaceIdProvider.notifier).state = 'rooms'; + }); + } + }); + } + } + } + + List> buildMenuItems() { + if (spaceId == 'dms' || spaceId == null) { + return [ + PopupMenuItem( + value: _SpaceMenuItem.newDm, + child: _MenuRow( + Icons.chat_bubble_outline_rounded, 'Neue Unterhaltung', pt)), + ]; + } else if (spaceId == 'rooms') { + return [ + PopupMenuItem( + value: _SpaceMenuItem.createRoom, + child: _MenuRow(Icons.add_rounded, 'Raum erstellen', pt)), + PopupMenuItem( + value: _SpaceMenuItem.joinRoom, + child: _MenuRow(Icons.login_rounded, 'Beitreten', pt)), + PopupMenuItem( + value: _SpaceMenuItem.discoverRooms, + child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)), + ]; + } else { + return [ + if (isAdmin) ...[ + PopupMenuItem( + value: _SpaceMenuItem.adminSpace, + child: _MenuRow( + Icons.admin_panel_settings_rounded, 'Space verwalten', pt)), + PopupMenuItem( + value: _SpaceMenuItem.editSpace, + child: _MenuRow(Icons.edit_outlined, 'Name/Beschreibung', pt)), + const PopupMenuDivider(), + ], + PopupMenuItem( + value: _SpaceMenuItem.leaveSpace, + child: _MenuRow(Icons.exit_to_app_rounded, 'Space verlassen', pt, + danger: true)), + ]; + } + } + + // Check for space banner — prefer local override (set immediately on save) + // over the state event (only available after next Matrix sync) + 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 hasBanner = bannerUrl != null && client != null; + + Widget controlsRow = Row( + children: [ + if (isMobile) + PyrIconBtn( + icon: const Icon(Icons.menu_rounded), + tooltip: 'Spaces', + onPressed: () => Scaffold.of(context).openDrawer(), + ) + else ...[ + PyrIconBtn( + icon: const Icon(Icons.menu_rounded), + tooltip: 'Toggle sidebar', + onPressed: () => + ref.read(railCollapsedProvider.notifier).update((s) => !s), + ), + const SizedBox(width: 4), + ], + // Space avatar (only when no banner) + if (isRealSpace && !hasBanner && client != null && activeSpace != null) ...[ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: pt.accent.withAlpha(40), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: activeSpace.avatar != null + ? MxcAvatar( + mxcUri: activeSpace.avatar, + client: client, + size: 28, + borderRadius: BorderRadius.circular(8), + placeholder: (_) => Center( + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: pt.accent, + fontSize: 13, + fontWeight: FontWeight.w700), + ), + ), + ) + : Center( + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: pt.accent, + fontSize: 13, + fontWeight: FontWeight.w700), + ), + ), + ), + ), + const SizedBox(width: 8), + ], + Expanded( + child: Text( + name, + style: TextStyle( + color: hasBanner ? Colors.white : pt.fg, + fontSize: 15, + fontWeight: isRealSpace ? FontWeight.w700 : FontWeight.w600, + shadows: hasBanner + ? [const Shadow(blurRadius: 4, color: Colors.black45)] + : null, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isRealSpace && isAdmin) + PyrIconBtn( + icon: Icon(Icons.admin_panel_settings_rounded, + color: hasBanner ? Colors.white70 : null), + tooltip: 'Space verwalten', + onPressed: () => handleMenu(_SpaceMenuItem.adminSpace), + ), + if (!isRealSpace) + PyrIconBtn( + icon: const Icon(Icons.notifications_none_rounded), + tooltip: 'Notifications', + onPressed: () {}, + ), + PopupMenuButton<_SpaceMenuItem>( + tooltip: 'Optionen', + color: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: pt.border)), + itemBuilder: (_) => buildMenuItems(), + onSelected: handleMenu, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(Icons.keyboard_arrow_down_rounded, + color: hasBanner ? Colors.white70 : pt.fgDim, size: 16), + ), + ), + ], + ); + + if (hasBanner) { + return SizedBox( + height: 110, + child: Stack( + fit: StackFit.expand, + children: [ + // Banner image + MxcImage( + mxcUri: bannerUrl, + client: client, + fit: BoxFit.cover, + placeholder: Container(color: pt.bg0), + ), + // Gradient overlay — bottom-heavy so text is readable + DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0.0, 0.4, 1.0], + colors: [ + Color(0x00000000), + Color(0x55000000), + Color(0xCC000000), + ], + ), + ), + ), + // Controls at the bottom + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: controlsRow, + ), + ), + // Bottom border + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container(height: 1, color: pt.border.withAlpha(80)), + ), + ], + ), + ); + } + + return Container( + height: isRealSpace ? 56 : 52, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: isRealSpace ? pt.bg0 : pt.bg1, + border: Border(bottom: BorderSide(color: pt.border)), + ), + child: controlsRow, + ); + } +} + +class _MenuRow extends StatelessWidget { + final IconData icon; + final String label; + final PyramidTheme pt; + final bool danger; + const _MenuRow(this.icon, this.label, this.pt, {this.danger = false}); + + @override + Widget build(BuildContext context) => Row(children: [ + Icon(icon, size: 14, color: danger ? pt.danger : pt.fgMuted), + const SizedBox(width: 8), + Text(label, + style: TextStyle( + color: danger ? pt.danger : pt.fg, fontSize: 13)), + ]); +} + +// ─── Filter input ────────────────────────────────────────────────────────────── + +class _FilterInput extends StatelessWidget { + final TextEditingController controller; + final PyramidTheme pt; + final ValueChanged onChanged; + const _FilterInput( + {required this.controller, required this.pt, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon(Icons.search_rounded, size: 14, color: pt.fgDim), + ), + TextField( + controller: controller, + onChanged: onChanged, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + hintText: 'Filter rooms', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), + contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7), + filled: true, + fillColor: pt.bg2, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: const BorderSide(color: Colors.transparent)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.accent, width: 1)), + isDense: true, + ), + cursorColor: pt.accent, + ), + ], + ), + ); + } +} + +// ─── Space rooms list ────────────────────────────────────────────────────────── + +class _SpaceRoomsList extends ConsumerStatefulWidget { + final String spaceId; + final String filter; + final Map collapsed; + final PyramidTheme pt; + final ValueChanged onToggleSection; + + const _SpaceRoomsList({ + required this.spaceId, + required this.filter, + required this.collapsed, + required this.pt, + required this.onToggleSection, + }); + + @override + ConsumerState<_SpaceRoomsList> createState() => _SpaceRoomsListState(); +} + +class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> { + // Local order override after drag-and-drop (room IDs in order per category key) + final Map> _localOrder = {}; + + Future _applyReorder( + Room space, String categoryKey, List newOrder) async { + try { + for (var i = 0; i < newOrder.length; i++) { + final child = newOrder[i]; + if (child.roomId == null) continue; + final newOrd = (i + 1).toString().padLeft(4, '0'); + if (child.order != newOrd) { + await space.setSpaceChild(child.roomId!, + via: child.via.isEmpty ? null : child.via, order: newOrd); + } + } + } catch (_) { + } + } + + @override + Widget build(BuildContext context) { + // Rebuild when rooms list changes (sync) + ref.watch(roomListProvider); + final client = ref.watch(matrixClientProvider).valueOrNull; + final activeRoomId = ref.watch(activeRoomIdProvider); + + if (client == null) { + return Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: widget.pt.accent))); + } + + final space = client.getRoomById(widget.spaceId); + if (space == null) { + return Center( + child: Text('Space nicht gefunden', + style: TextStyle(color: widget.pt.fgDim, fontSize: 12))); + } + + final isAdmin = space.ownPowerLevel >= 50; + final allChildren = space.spaceChildren; + + // Separate sub-spaces (categories) from direct rooms + final subSpaceEntries = []; + final directEntries = []; + for (final c in allChildren) { + if (c.roomId == null) continue; + final room = client.getRoomById(c.roomId!); + if (room == null || room.membership == Membership.leave) continue; + if (room.isSpace) { + subSpaceEntries.add(c); + } else { + directEntries.add(c); + } + } + subSpaceEntries.sort(_compareSpaceChildren); + directEntries.sort(_compareSpaceChildren); + + // Collect invited rooms + final invites = allChildren + .where((c) => c.roomId != null) + .map((c) => client.getRoomById(c.roomId!)) + .whereType() + .where((r) => r.membership == Membership.invite) + .toList(); + + void selectRoom(Room room) { + ref.read(activeRoomIdProvider.notifier).state = room.id; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + } + + void joinVoiceChannel(Room room) async { + final call = ref.read(callStateProvider); + final activeVoice = ref.read(activeVoiceRoomIdProvider); + // Toggle: leave if already in this channel + if (call.isVoiceChannel && activeVoice == room.id) { + ref.read(activeVoiceRoomIdProvider.notifier).state = null; + await call.hangUp(); + return; + } + if (call.isActive) await call.hangUp(); + ref.read(activeVoiceRoomIdProvider.notifier).state = room.id; + final userId = client.userID ?? ''; + await call.startCall( + roomName: room.id, + roomDisplayName: room.getLocalizedDisplayname(), + identity: userId, + audioOnly: true, + voiceChannel: true, + matrixClient: client, + matrixRoomId: room.id, + ); + } + + // Build category sections for sub-spaces + List categoryWidgets = []; + for (final catEntry in subSpaceEntries) { + final subSpace = client.getRoomById(catEntry.roomId!)!; + final catId = subSpace.id; + final catName = subSpace.getLocalizedDisplayname(); + final isCollapsed = widget.collapsed[catId] ?? false; + + final catChildren = subSpace.spaceChildren + .where((c) => c.roomId != null) + .map((c) => client.getRoomById(c.roomId!)) + .whereType() + .where((r) => + r.membership != Membership.leave && !r.isSpace) + .toList(); + + final filtered = catChildren + .where((r) => widget.filter.isEmpty || + r.getLocalizedDisplayname() + .toLowerCase() + .contains(widget.filter.toLowerCase())) + .toList(); + + categoryWidgets.add(_CategoryHeader( + name: catName.toUpperCase(), + isCollapsed: isCollapsed, + pt: widget.pt, + onTap: () => widget.onToggleSection(catId), + )); + if (!isCollapsed) { + for (final room in filtered) { + final isVoice = _isVoiceRoom(room); + categoryWidgets.add(_RoomItem( + key: ValueKey('cat_${room.id}'), + room: room, + isActive: isVoice ? false : activeRoomId == room.id, + isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == room.id, + pt: widget.pt, + onTap: () => isVoice ? joinVoiceChannel(room) : selectRoom(room), + )); + } + } + } + + // Build direct rooms section (reorderable for admin) + final directRooms = directEntries + .map((c) => client.getRoomById(c.roomId!)) + .whereType() + .toList(); + + // Apply local order override if present + final localOrd = _localOrder['__direct__']; + if (localOrd != null) { + directRooms.sort((a, b) { + final ai = localOrd.indexOf(a.id); + final bi = localOrd.indexOf(b.id); + if (ai == -1 && bi == -1) return 0; + if (ai == -1) return 1; + if (bi == -1) return -1; + return ai.compareTo(bi); + }); + } + + final filteredDirect = directRooms + .where((r) => widget.filter.isEmpty || + r.getLocalizedDisplayname() + .toLowerCase() + .contains(widget.filter.toLowerCase())) + .toList(); + + // Build the complete list + return ListView( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), + children: [ + // Invite cards + if (invites.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 2), + child: Text('EINLADUNGEN', + style: TextStyle( + color: widget.pt.accent, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.6)), + ), + ...invites.map((r) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: _InviteItem(room: r, pt: widget.pt))), + const SizedBox(height: 4), + ], + // Sub-space categories + ...categoryWidgets, + // Direct rooms + if (filteredDirect.isNotEmpty) ...[ + if (subSpaceEntries.isNotEmpty) + _CategoryHeader( + name: 'KANÄLE', + isCollapsed: widget.collapsed['__direct__'] ?? false, + pt: widget.pt, + onTap: () => widget.onToggleSection('__direct__'), + ), + if (!(widget.collapsed['__direct__'] ?? false)) + if (isAdmin) + // Reorderable list for admins + ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + onReorder: (oldIdx, newIdx) { + if (newIdx > oldIdx) newIdx--; + final ids = filteredDirect.map((r) => r.id).toList(); + final moved = ids.removeAt(oldIdx); + ids.insert(newIdx, moved); + setState(() => _localOrder['__direct__'] = ids); + // Apply to Matrix + final newOrder = ids + .map((id) => + directEntries.firstWhere((c) => c.roomId == id, + orElse: () => directEntries.first)) + .toList(); + _applyReorder(space, '__direct__', newOrder); + }, + children: [ + for (var i = 0; i < filteredDirect.length; i++) + _DraggableRoomItem( + key: ValueKey(filteredDirect[i].id), + room: filteredDirect[i], + index: i, + isActive: _isVoiceRoom(filteredDirect[i]) ? false : activeRoomId == filteredDirect[i].id, + isInVoice: _isVoiceRoom(filteredDirect[i]) && ref.read(activeVoiceRoomIdProvider) == filteredDirect[i].id, + pt: widget.pt, + onTap: () => _isVoiceRoom(filteredDirect[i]) ? joinVoiceChannel(filteredDirect[i]) : selectRoom(filteredDirect[i]), + ), + ], + ) + else + ...filteredDirect.map((r) { + final isVoice = _isVoiceRoom(r); + return _RoomItem( + key: ValueKey(r.id), + room: r, + isActive: isVoice ? false : activeRoomId == r.id, + isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == r.id, + pt: widget.pt, + onTap: () => isVoice ? joinVoiceChannel(r) : selectRoom(r), + ); + }), + ], + if (filteredDirect.isEmpty && + categoryWidgets.isEmpty && + invites.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: Text('Keine Kanäle', + style: + TextStyle(color: widget.pt.fgDim, fontSize: 13))), + ), + ], + ); + } +} + +// ─── Category header ─────────────────────────────────────────────────────────── + +class _CategoryHeader extends StatefulWidget { + final String name; + final bool isCollapsed; + final PyramidTheme pt; + final VoidCallback onTap; + const _CategoryHeader( + {required this.name, + required this.isCollapsed, + required this.pt, + required this.onTap}); + + @override + State<_CategoryHeader> createState() => _CategoryHeaderState(); +} + +class _CategoryHeaderState extends State<_CategoryHeader> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 10, 8, 2), + child: Row(children: [ + AnimatedRotation( + turns: widget.isCollapsed ? -0.25 : 0, + duration: const Duration(milliseconds: 180), + child: Icon(Icons.keyboard_arrow_down_rounded, + size: 12, + color: _hovered ? pt.fgMuted : pt.fgDim), + ), + const SizedBox(width: 4), + Text( + widget.name, + style: TextStyle( + color: _hovered ? pt.fgMuted : pt.fgDim, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ]), + ), + ), + ); + } +} + +// ─── Draggable room item (admin only) ───────────────────────────────────────── + +class _DraggableRoomItem extends StatefulWidget { + final Room room; + final int index; + final bool isActive; + final bool isInVoice; + final PyramidTheme pt; + final VoidCallback onTap; + const _DraggableRoomItem({ + super.key, + required this.room, + required this.index, + required this.isActive, + this.isInVoice = false, + required this.pt, + required this.onTap, + }); + + @override + State<_DraggableRoomItem> createState() => _DraggableRoomItemState(); +} + +class _DraggableRoomItemState extends State<_DraggableRoomItem> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Row(children: [ + ReorderableDragStartListener( + index: widget.index, + child: MouseRegion( + cursor: SystemMouseCursors.grab, + child: AnimatedOpacity( + opacity: _hovered ? 1.0 : 0.0, + duration: const Duration(milliseconds: 120), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: Icon(Icons.drag_indicator_rounded, + size: 12, color: widget.pt.fgDim), + ), + ), + ), + ), + Expanded( + child: _RoomItem( + room: widget.room, + isActive: widget.isActive, + isInVoice: widget.isInVoice, + pt: widget.pt, + onTap: widget.onTap, + ), + ), + ]), + ); + } +} + +// ─── Regular rooms list (DMs + all-rooms) ───────────────────────────────────── + +class _RoomsList extends ConsumerWidget { + final List rooms; + final String filter; + final String? spaceId; + final Map collapsed; + final PyramidTheme pt; + final ValueChanged onToggleSection; + + const _RoomsList({ + required this.rooms, + required this.filter, + required this.spaceId, + required this.collapsed, + required this.pt, + required this.onToggleSection, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeRoomId = ref.watch(activeRoomIdProvider); + final isDms = spaceId == 'dms'; + + final invites = rooms + .where((r) => + r.membership == Membership.invite && + isDms == r.isDirectChat) + .toList(); + + final filtered = rooms.where((r) { + if (r.membership == Membership.invite) return false; + if (isDms != r.isDirectChat) return false; + if (filter.isEmpty) return true; + return r.getLocalizedDisplayname() + .toLowerCase() + .contains(filter.toLowerCase()); + }).toList(); + + if (filtered.isEmpty && invites.isEmpty) { + return Center( + child: + Text('No rooms', style: TextStyle(color: pt.fgDim, fontSize: 13))); + } + + final sectionTitle = isDms ? 'Direct Messages' : 'Rooms'; + final sectionId = isDms ? 'dms' : 'rooms'; + final isCollapsed = collapsed[sectionId] ?? false; + + return ListView( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), + children: [ + if (invites.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 2), + child: Text('EINLADUNGEN', + style: TextStyle( + color: pt.accent, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.6)), + ), + ...invites.map((r) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: _InviteItem(room: r, pt: pt))), + const SizedBox(height: 4), + ], + _SectionHeader( + title: sectionTitle, + count: filtered.length, + isCollapsed: isCollapsed, + pt: pt, + onToggle: () => onToggleSection(sectionId), + onAddTap: isDms + ? () => showDialog( + context: context, builder: (_) => const NewDmDialog()) + : () => showDialog( + context: context, builder: (_) => const CreateJoinDialog()), + ), + if (!isCollapsed) + ...filtered.map((room) => _RoomItem( + room: room, + isActive: activeRoomId == room.id, + pt: pt, + onTap: () { + ref.read(activeRoomIdProvider.notifier).state = room.id; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + }, + )), + ], + ); + } +} + +// ─── Section header ──────────────────────────────────────────────────────────── + +class _SectionHeader extends StatefulWidget { + final String title; + final int count; + final bool isCollapsed; + final PyramidTheme pt; + final VoidCallback onToggle; + final VoidCallback? onAddTap; + + const _SectionHeader({ + required this.title, + required this.count, + required this.isCollapsed, + required this.pt, + required this.onToggle, + this.onAddTap, + }); + + @override + State<_SectionHeader> createState() => _SectionHeaderState(); +} + +class _SectionHeaderState extends State<_SectionHeader> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Container( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), + child: Row(children: [ + Expanded( + child: GestureDetector( + onTap: widget.onToggle, + child: Row(children: [ + AnimatedRotation( + turns: widget.isCollapsed ? -0.25 : 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + child: Icon(Icons.keyboard_arrow_down_rounded, + size: 14, + color: _hovered ? pt.fgMuted : pt.fgDim), + ), + const SizedBox(width: 4), + Text(widget.title.toUpperCase(), + style: TextStyle( + color: _hovered ? pt.fgMuted : pt.fgDim, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.04 * 11)), + const Spacer(), + Text('${widget.count}', + style: TextStyle(color: pt.fgDim, fontSize: 11)), + ]), + ), + ), + if (_hovered && widget.onAddTap != null) ...[ + const SizedBox(width: 4), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onAddTap, + child: SizedBox( + width: 18, + height: 18, + child: Center( + child: Icon(Icons.add, size: 12, color: pt.fgMuted)), + ), + ), + ] else + const SizedBox(width: 22), + ]), + ), + ); + } +} + +// ─── Room item ───────────────────────────────────────────────────────────────── + +class _RoomItem extends ConsumerStatefulWidget { + final Room room; + final bool isActive; + final bool isInVoice; + final PyramidTheme pt; + final VoidCallback onTap; + + const _RoomItem({ + super.key, + required this.room, + required this.isActive, + this.isInVoice = false, + required this.pt, + required this.onTap, + }); + + @override + ConsumerState<_RoomItem> createState() => _RoomItemState(); +} + +class _RoomItemState extends ConsumerState<_RoomItem> { + bool _hovered = false; + + Future _leaveRoom() async { + final pt = widget.pt; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + title: Text('Raum verlassen?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text( + '"${widget.room.getLocalizedDisplayname()}" verlassen?', + style: TextStyle(color: pt.fgMuted, fontSize: 13)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.danger, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Verlassen'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + final id = widget.room.id; + await widget.room.leave(); + if (mounted && ref.read(activeRoomIdProvider) == id) { + ref.read(activeRoomIdProvider.notifier).state = null; + } + } catch (_) {} + } + + Future _leaveDm() async { + final pt = widget.pt; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + title: Text('Chat verlassen?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text( + 'Unterhaltung mit "${widget.room.getLocalizedDisplayname()}" schließen?', + style: TextStyle(color: pt.fgMuted, fontSize: 13)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.danger, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Verlassen'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + final id = widget.room.id; + await widget.room.leave(); + if (mounted) { + if (ref.read(activeRoomIdProvider) == id) { + ref.read(activeRoomIdProvider.notifier).state = null; + } + } + } catch (_) {} + } + + Future _showRoomSettings() async { + await showDialog( + context: context, + builder: (_) => RoomSettingsDialog(room: widget.room), + ); + } + + Future _inviteUser() async { + final pt = widget.pt; + final ctrl = TextEditingController(); + final userId = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + title: Text('Nutzer einladen', + style: TextStyle( + color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: TextField( + controller: ctrl, + autofocus: true, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + hintText: '@nutzer:server', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 12), + filled: true, + fillColor: pt.bg3, + isDense: true, + 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)), + ), + cursorColor: pt.accent, + onSubmitted: (v) => Navigator.pop(ctx, v.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), + TextButton( + onPressed: () => Navigator.pop(ctx, ctrl.text.trim()), + child: Text('Einladen', style: TextStyle(color: pt.accent))), + ], + ), + ); + ctrl.dispose(); + if (userId == null || userId.isEmpty || !mounted) return; + try { + await widget.room.invite(userId); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Fehler: ${e.toString().split('\n').first}'), + backgroundColor: widget.pt.danger, + )); + } + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final room = widget.room; + final active = widget.isActive; + final inVoice = widget.isInVoice; + final name = room.getLocalizedDisplayname(); + final unread = room.notificationCount; + final isDm = room.isDirectChat; + final isVoice = _isVoiceRoom(room); + final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : >[]; + final client = ref.watch(matrixClientProvider).valueOrNull; + + Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: Stack( + children: [ + if (active) + Positioned( + left: 0, + top: 6, + bottom: 6, + child: Container( + width: 3, + decoration: BoxDecoration( + color: pt.accent, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(2), + bottomRight: Radius.circular(2), + ), + ), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + margin: const EdgeInsets.symmetric(vertical: 1), + padding: EdgeInsets.symmetric( + horizontal: pt.rowPadX, vertical: pt.rowPadY), + decoration: BoxDecoration( + color: active + ? pt.accentSoft + : inVoice + ? const Color(0xFF57F287).withAlpha(25) + : _hovered ? pt.bgHover : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + if (isDm) ...[ + _DmAvatar(room: room, pt: pt), + ] else ...[ + SizedBox( + width: 16, + height: 16, + child: Icon( + isVoice ? Icons.mic_rounded : _roomIcon(room), + size: 14, + color: isVoice + ? (inVoice ? const Color(0xFF57F287) : _hovered ? const Color(0xFF57F287) : const Color(0xFF57F287).withAlpha(180)) + : (active ? pt.accent : _hovered ? pt.fg : pt.fgDim), + ), + ), + ], + const SizedBox(width: 8), + Expanded( + child: Text( + name, + style: TextStyle( + color: textColor, + fontSize: 14, + fontWeight: unread > 0 || active || inVoice + ? FontWeight.w600 + : FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isVoice && voiceParticipants.isNotEmpty) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Text( + '${voiceParticipants.length}', + style: TextStyle( + color: const Color(0xFF57F287).withAlpha(200), + fontSize: 11, + fontWeight: FontWeight.w600), + ), + ), + if (!isVoice && _hovered) ...[ + if (isDm) + _HoverAction(icon: Icons.close, pt: pt, onTap: _leaveDm) + else + _RoomMenuBtn( + pt: pt, + onInvite: _inviteUser, + onSettings: _showRoomSettings, + onLeave: _leaveRoom, + ), + ] else if (!isVoice && unread > 0) ...[ + _UnreadBadge( + count: unread, + isMention: room.highlightCount > 0, + pt: pt), + ], + ], + ), + // Voice participants (Discord-style named rows) + if (isVoice && voiceParticipants.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2, left: 24), + child: _VoiceParticipantList( + participants: voiceParticipants, + client: client, + pt: pt, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + IconData _roomIcon(Room room) { + if (room.encrypted) return Icons.lock_outline_rounded; + return Icons.tag_rounded; + } +} + +// ─── Voice participant list (Discord-style named rows) ──────────────────────── + +class _VoiceParticipantList extends StatelessWidget { + final List> participants; + final Client? client; + final PyramidTheme pt; + const _VoiceParticipantList( + {required this.participants, required this.client, required this.pt}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: participants.map((p) { + final avatarUrl = p['avatarUrl'] as String?; + final name = (p['displayName'] as String? ?? '?'); + final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; + return Padding( + padding: const EdgeInsets.only(top: 3), + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: client != null && avatarUrl != null + ? MxcAvatar( + mxcUri: Uri.tryParse(avatarUrl), + client: client!, + size: 16, + borderRadius: BorderRadius.circular(8), + placeholder: (_) => _Initials(letter: letter, pt: pt, size: 16), + ) + : _Initials(letter: letter, pt: pt, size: 16), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + name, + style: TextStyle( + color: const Color(0xFF57F287).withAlpha(200), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Icon(Icons.mic_rounded, + size: 11, + color: const Color(0xFF57F287).withAlpha(140)), + ], + ), + ); + }).toList(), + ); + } +} + +class _Initials extends StatelessWidget { + final String letter; + final PyramidTheme pt; + final double size; + const _Initials({required this.letter, required this.pt, this.size = 18}); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(size / 2), + ), + child: Center( + child: Text(letter, + style: TextStyle( + color: pt.fg, + fontSize: size * 0.5, + fontWeight: FontWeight.w700)), + ), + ); + } +} + +class _VoiceBarBtn extends StatefulWidget { + final IconData icon; + final bool active; + final Color activeColor; + final Color inactiveColor; + final PyramidTheme pt; + final VoidCallback onTap; + + const _VoiceBarBtn({ + required this.icon, + required this.active, + required this.activeColor, + required this.inactiveColor, + required this.pt, + required this.onTap, + }); + + @override + State<_VoiceBarBtn> createState() => _VoiceBarBtnState(); +} + +class _VoiceBarBtnState extends State<_VoiceBarBtn> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final color = widget.active ? widget.activeColor : widget.inactiveColor; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 28, + height: 28, + decoration: BoxDecoration( + color: _hovered ? pt.bg3 : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Center(child: Icon(widget.icon, size: 16, color: color)), + ), + ), + ); + } +} + +// ─── Invite item ─────────────────────────────────────────────────────────────── + +class _InviteItem extends ConsumerStatefulWidget { + final Room room; + final PyramidTheme pt; + const _InviteItem({required this.room, required this.pt}); + + @override + ConsumerState<_InviteItem> createState() => _InviteItemState(); +} + +class _InviteItemState extends ConsumerState<_InviteItem> { + bool _accepting = false; + bool _declining = false; + + Future _accept() async { + setState(() => _accepting = true); + try { + await widget.room.join(); + if (mounted) { + ref.read(activeRoomIdProvider.notifier).state = widget.room.id; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + } + } catch (_) { + if (mounted) setState(() => _accepting = false); + } + } + + Future _decline() async { + setState(() => _declining = true); + try { + await widget.room.leave(); + } catch (_) { + if (mounted) setState(() => _declining = false); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final name = widget.room.getLocalizedDisplayname(); + final isDm = widget.room.isDirectChat; + return Container( + margin: const EdgeInsets.symmetric(vertical: 1), + padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), + decoration: BoxDecoration( + color: pt.accentSoft, + borderRadius: BorderRadius.circular(pt.rSm), + border: Border.all(color: pt.accent.withAlpha(50)), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(isDm ? Icons.person_rounded : Icons.tag_rounded, + size: 12, color: pt.accent), + const SizedBox(width: 6), + Expanded( + child: Text(name, + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis), + ), + ]), + const SizedBox(height: 6), + Row(children: [ + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: pt.danger, + side: BorderSide(color: pt.danger.withAlpha(80)), + padding: const EdgeInsets.symmetric(vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6)), + ), + onPressed: (_declining || _accepting) ? null : _decline, + child: _declining + ? const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator.adaptive(strokeWidth: 1.5)) + : const Text('Ablehnen', style: TextStyle(fontSize: 12)), + ), + ), + const SizedBox(width: 6), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6)), + ), + onPressed: (_accepting || _declining) ? null : _accept, + child: _accepting + ? const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator.adaptive(strokeWidth: 1.5)) + : const Text('Annehmen', style: TextStyle(fontSize: 12)), + ), + ), + ]), + ]), + ); + } +} + +// ─── DM avatar ───────────────────────────────────────────────────────────────── + +class _DmAvatar extends ConsumerStatefulWidget { + final Room room; + final PyramidTheme pt; + const _DmAvatar({required this.room, required this.pt}); + + @override + ConsumerState<_DmAvatar> createState() => _DmAvatarState(); +} + +class _DmAvatarState extends ConsumerState<_DmAvatar> { + CachedPresence? _presence; + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + _loadPresence(); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + Future _loadPresence() async { + final client = ref.read(matrixClientProvider).valueOrNull; + if (client == null) return; + final userId = widget.room.directChatMatrixID; + if (userId == null) return; + try { + final p = await client.fetchCurrentPresence(userId); + if (mounted) setState(() => _presence = p); + } catch (_) {} + _sub = client.onPresenceChanged.stream + .where((p) => p.userid == userId) + .listen((p) { + if (mounted) setState(() => _presence = p); + }); + } + + Color? _dotColor() { + final p = _presence; + if (p == null) return null; + if (p.currentlyActive == true) return PyramidColors.online; + if (p.presence == PresenceType.unavailable) return PyramidColors.away; + return null; + } + + @override + Widget build(BuildContext context) { + final name = widget.room.getLocalizedDisplayname(); + final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; + final color = _colorFromName(name); + final client = ref.watch(matrixClientProvider).valueOrNull; + + Widget avatar; + if (client != null && widget.room.avatar != null) { + avatar = MxcAvatar( + mxcUri: widget.room.avatar, + client: client, + size: 22, + borderRadius: BorderRadius.circular(11), + placeholder: (_) => + _InitialCircle(initial: initial, color: color, size: 22), + ); + } else { + avatar = _InitialCircle(initial: initial, color: color, size: 22); + } + + final dot = _dotColor(); + if (dot == null) return avatar; + return Stack(clipBehavior: Clip.none, children: [ + avatar, + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 9, + height: 9, + decoration: BoxDecoration( + color: dot, + shape: BoxShape.circle, + border: Border.all(color: widget.pt.bg1, width: 1.5), + ), + ), + ), + ]); + } + + 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]; + } +} + +class _InitialCircle extends StatelessWidget { + final String initial; + final Color color; + final double size; + const _InitialCircle( + {required this.initial, required this.color, required this.size}); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + child: Center( + child: Text(initial, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600))), + ); + } +} + +// ─── Hover action ────────────────────────────────────────────────────────────── + +class _RoomMenuBtn extends StatefulWidget { + final PyramidTheme pt; + final VoidCallback onInvite; + final VoidCallback onSettings; + final VoidCallback onLeave; + const _RoomMenuBtn({required this.pt, required this.onInvite, required this.onSettings, required this.onLeave}); + + @override + State<_RoomMenuBtn> createState() => _RoomMenuBtnState(); +} + +class _RoomMenuBtnState extends State<_RoomMenuBtn> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + color: widget.pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: widget.pt.border)), + offset: const Offset(-100, 24), + tooltip: '', + onSelected: (v) { + if (v == 'invite') widget.onInvite(); + if (v == 'settings') widget.onSettings(); + if (v == 'leave') widget.onLeave(); + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'invite', + height: 36, + child: Row(children: [ + Icon(Icons.person_add_outlined, size: 14, color: widget.pt.fgMuted), + const SizedBox(width: 8), + Text('Nutzer einladen', style: TextStyle(color: widget.pt.fg, fontSize: 13)), + ]), + ), + PopupMenuItem( + value: 'settings', + height: 36, + child: Row(children: [ + Icon(Icons.settings_rounded, size: 14, color: widget.pt.fgMuted), + const SizedBox(width: 8), + Text('Einstellungen', style: TextStyle(color: widget.pt.fg, fontSize: 13)), + ]), + ), + PopupMenuDivider(height: 1), + PopupMenuItem( + value: 'leave', + height: 36, + child: Row(children: [ + Icon(Icons.exit_to_app_rounded, size: 14, color: widget.pt.danger), + const SizedBox(width: 8), + Text('Raum verlassen', style: TextStyle(color: widget.pt.danger, fontSize: 13)), + ]), + ), + ], + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 20, + height: 20, + decoration: BoxDecoration( + color: _hovered ? widget.pt.bgActive : Colors.transparent, + borderRadius: BorderRadius.circular(4), + ), + child: Center( + child: Icon(Icons.more_horiz_rounded, + size: 12, + color: _hovered ? widget.pt.fg : widget.pt.fgDim)), + ), + ), + ); + } +} + +class _HoverAction extends StatefulWidget { + final IconData icon; + final PyramidTheme pt; + final VoidCallback? onTap; + const _HoverAction({required this.icon, required this.pt, this.onTap}); + + @override + State<_HoverAction> createState() => _HoverActionState(); +} + +class _HoverActionState extends State<_HoverAction> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + child: MouseRegion( + cursor: widget.onTap != null + ? SystemMouseCursors.click + : MouseCursor.defer, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 20, + height: 20, + decoration: BoxDecoration( + color: _hovered ? widget.pt.bgActive : Colors.transparent, + borderRadius: BorderRadius.circular(4), + ), + child: Center( + child: Icon(widget.icon, + size: 12, + color: _hovered ? widget.pt.fg : widget.pt.fgDim)), + ), + ), + ); + } +} + +// ─── Unread badge ────────────────────────────────────────────────────────────── + +class _UnreadBadge extends StatelessWidget { + final int count; + final bool isMention; + final PyramidTheme pt; + const _UnreadBadge( + {required this.count, required this.isMention, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + decoration: BoxDecoration( + color: isMention ? pt.danger : pt.accent, + borderRadius: BorderRadius.circular(999), + ), + child: Center( + child: Text(count > 99 ? '99+' : '$count', + style: TextStyle( + color: isMention ? Colors.white : pt.accentFg, + fontSize: 10, + fontWeight: FontWeight.w700))), + ); + } +} diff --git a/lib/widgets/update_banner.dart b/lib/widgets/update_banner.dart new file mode 100644 index 0000000..d254e12 --- /dev/null +++ b/lib/widgets/update_banner.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/core/update_checker.dart'; +import 'package:pyramid/widgets/update_download_dialog.dart'; + +/// Slim top banner shown when a new Pyramid version is available. +class UpdateBanner extends ConsumerWidget { + const UpdateBanner({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ref.watch(updateInfoProvider).when( + loading: () => const SizedBox.shrink(), + error: (e, _) => const SizedBox.shrink(), + data: (info) => + info == null ? const SizedBox.shrink() : _Banner(info: info), + ); + } +} + +class _Banner extends ConsumerWidget { + final UpdateInfo info; + const _Banner({required this.info}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pt = PyramidTheme.of(context); + final canDownload = info.canDownload; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: pt.accent.withAlpha(28), + border: Border( + bottom: BorderSide(color: pt.accent.withAlpha(80)), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.system_update_alt_rounded, size: 15, color: pt.accent), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Pyramid ${info.tagName} ist verfügbar', + style: TextStyle( + color: pt.fg, fontSize: 12.5, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + _BannerBtn( + label: 'Was ist neu', + icon: Icons.open_in_new_rounded, + color: pt.bg3, + fgColor: pt.fg, + onTap: () => _openUrl(info.htmlUrl), + ), + const SizedBox(width: 6), + _BannerBtn( + label: canDownload ? 'Herunterladen' : 'Öffnen', + icon: canDownload + ? Icons.download_rounded + : Icons.open_in_new_rounded, + color: pt.accent, + fgColor: pt.accentFg, + onTap: () { + if (canDownload) { + showUpdateDownloadDialog(context, info); + } else { + _openUrl(info.htmlUrl); + } + }, + ), + const SizedBox(width: 6), + Tooltip( + message: 'Für diese Version ausblenden', + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () async { + await skipUpdateVersion(info.tagName); + ref.invalidate(updateInfoProvider); + }, + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon(Icons.close_rounded, size: 14, color: pt.fgDim), + ), + ), + ), + ], + ), + ); + } + + Future _openUrl(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } +} + +class _BannerBtn extends StatelessWidget { + final String label; + final IconData icon; + final Color color; + final Color fgColor; + final VoidCallback onTap; + + const _BannerBtn({ + required this.label, + required this.icon, + required this.color, + required this.fgColor, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(6), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: + BoxDecoration(color: color, borderRadius: BorderRadius.circular(6)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: fgColor), + const SizedBox(width: 4), + Text(label, + style: TextStyle( + color: fgColor, + fontSize: 12, + fontWeight: FontWeight.w600)), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/update_download_dialog.dart b/lib/widgets/update_download_dialog.dart new file mode 100644 index 0000000..4a4a384 --- /dev/null +++ b/lib/widgets/update_download_dialog.dart @@ -0,0 +1,384 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:open_file_plus/open_file_plus.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/core/update_checker.dart'; +import 'package:pyramid/widgets/pyramid_logo.dart'; + +// ─── Entry point ────────────────────────────────────────────────────────────── + +Future showUpdateDownloadDialog(BuildContext context, UpdateInfo info) { + return showDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.black.withAlpha(160), + builder: (_) => _UpdateDownloadDialog(info: info), + ); +} + +// ─── States ─────────────────────────────────────────────────────────────────── + +enum _DownloadState { idle, downloading, installing, done, error } + +// ─── Dialog widget ──────────────────────────────────────────────────────────── + +class _UpdateDownloadDialog extends ConsumerStatefulWidget { + final UpdateInfo info; + const _UpdateDownloadDialog({required this.info}); + + @override + ConsumerState<_UpdateDownloadDialog> createState() => + _UpdateDownloadDialogState(); +} + +class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog> + with SingleTickerProviderStateMixin { + late final AnimationController _spinCtrl; + + _DownloadState _state = _DownloadState.idle; + double _progress = 0.0; + int _received = 0; + int _total = 0; + String _errorMsg = ''; + + @override + void initState() { + super.initState(); + _spinCtrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 3), + )..repeat(); + _startDownload(); + } + + @override + void dispose() { + _spinCtrl.dispose(); + super.dispose(); + } + + // ─── Download ─────────────────────────────────────────────────────────────── + + Future _startDownload() async { + setState(() { + _state = _DownloadState.downloading; + _progress = 0; + _received = 0; + _total = 0; + }); + + try { + final dir = await getTemporaryDirectory(); + final downloadDir = Directory(p.join(dir.path, 'downloads')); + await downloadDir.create(recursive: true); + + final ext = Platform.isWindows ? '.exe' : '.apk'; + final fileName = 'pyramid-update-${widget.info.tagName}$ext'; + final file = File(p.join(downloadDir.path, fileName)); + final request = http.Request('GET', Uri.parse(widget.info.downloadUrl!)); + final streamedResponse = + await http.Client().send(request).timeout(const Duration(minutes: 5)); + + _total = streamedResponse.contentLength ?? 0; + final sink = file.openWrite(); + + await for (final chunk in streamedResponse.stream) { + sink.add(chunk); + _received += chunk.length; + if (mounted) { + setState(() { + _progress = _total > 0 ? _received / _total : 0; + }); + } + } + await sink.flush(); + await sink.close(); + + if (!mounted) return; + await _install(file); + } catch (e) { + if (mounted) { + setState(() { + _state = _DownloadState.error; + _errorMsg = e.toString().split('\n').first; + }); + } + } + } + + Future _install(File file) async { + setState(() => _state = _DownloadState.installing); + + if (Platform.isWindows) { + await Process.start(file.path, [], runInShell: false); + if (mounted) Navigator.of(context).pop(); + exit(0); + } else if (Platform.isAndroid) { + final result = await OpenFile.open( + file.path, + type: 'application/vnd.android.package-archive', + ); + if (mounted) { + if (result.type == ResultType.done) { + setState(() => _state = _DownloadState.done); + } else { + setState(() { + _state = _DownloadState.error; + _errorMsg = result.message; + }); + } + } + } + } + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + String _formatBytes(int bytes) { + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + // ─── UI ───────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + child: Container( + width: 320, + padding: const EdgeInsets.fromLTRB(32, 36, 32, 28), + decoration: BoxDecoration( + color: pt.bg1, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: pt.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(120), + blurRadius: 40, + spreadRadius: 4, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── Animated Pyramid ── + _AnimatedPyramid(ctrl: _spinCtrl, pt: pt), + const SizedBox(height: 24), + + // ── Title ── + Text( + _titleText(), + style: TextStyle( + color: pt.fg, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + widget.info.tagName, + style: TextStyle(color: pt.accent, fontSize: 13), + ), + const SizedBox(height: 24), + + // ── Progress / Status ── + if (_state == _DownloadState.downloading) ...[ + _ProgressBar(progress: _progress, pt: pt), + const SizedBox(height: 10), + Text( + _progressLabel(), + style: TextStyle(color: pt.fgMuted, fontSize: 12), + ), + ] else if (_state == _DownloadState.installing) ...[ + _ProgressBar(progress: 1.0, pt: pt), + const SizedBox(height: 10), + Text( + 'Wird installiert…', + style: TextStyle(color: pt.fgMuted, fontSize: 12), + ), + ] else if (_state == _DownloadState.done) ...[ + Icon(Icons.check_circle_rounded, color: pt.accent, size: 32), + const SizedBox(height: 8), + Text( + 'Update installiert', + style: TextStyle(color: pt.fgMuted, fontSize: 12), + ), + ] else if (_state == _DownloadState.error) ...[ + Icon(Icons.error_outline_rounded, + color: pt.danger, size: 32), + const SizedBox(height: 8), + Text( + _errorMsg, + style: TextStyle(color: pt.danger, fontSize: 11), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + + const SizedBox(height: 24), + + // ── Action buttons ── + if (_state == _DownloadState.error) + Row( + children: [ + Expanded( + child: _DialogBtn( + label: 'Abbrechen', + pt: pt, + onTap: () => Navigator.of(context).pop(), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _DialogBtn( + label: 'Erneut', + pt: pt, + primary: true, + onTap: _startDownload, + ), + ), + ], + ) + else if (_state == _DownloadState.done) + _DialogBtn( + label: 'Schließen', + pt: pt, + onTap: () => Navigator.of(context).pop(), + ) + else if (_state == _DownloadState.downloading) + _DialogBtn( + label: 'Abbrechen', + pt: pt, + onTap: () => Navigator.of(context).pop(), + ), + ], + ), + ), + ); + } + + String _titleText() => switch (_state) { + _DownloadState.idle => 'Vorbereitung…', + _DownloadState.downloading => 'Update wird geladen', + _DownloadState.installing => 'Wird installiert', + _DownloadState.done => 'Fertig!', + _DownloadState.error => 'Fehler beim Download', + }; + + String _progressLabel() { + final pct = (_progress * 100).toStringAsFixed(0); + if (_total > 0) { + return '$pct% · ${_formatBytes(_received)} / ${_formatBytes(_total)}'; + } + return '$pct% · ${_formatBytes(_received)}'; + } +} + +// ─── Animated pyramid ───────────────────────────────────────────────────────── + +class _AnimatedPyramid extends StatelessWidget { + final AnimationController ctrl; + final PyramidTheme pt; + const _AnimatedPyramid({required this.ctrl, required this.pt}); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: ctrl, + builder: (context, _) { + final pulse = 0.9 + 0.1 * (0.5 - (ctrl.value - 0.5).abs()) * 2; + return Transform.scale( + scale: pulse, + child: PyramidLogo(size: 72, color: pt.accent), + ); + }, + ); + } +} + +// ─── Progress bar ───────────────────────────────────────────────────────────── + +class _ProgressBar extends StatelessWidget { + final double progress; + final PyramidTheme pt; + const _ProgressBar({required this.progress, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + height: 6, + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(3), + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: progress.clamp(0.0, 1.0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: pt.accent, + borderRadius: BorderRadius.circular(3), + boxShadow: [ + BoxShadow( + color: pt.accent.withAlpha(100), + blurRadius: 6, + spreadRadius: 1, + ), + ], + ), + ), + ), + ); + } +} + +// ─── Dialog button ──────────────────────────────────────────────────────────── + +class _DialogBtn extends StatelessWidget { + final String label; + final PyramidTheme pt; + final bool primary; + final VoidCallback onTap; + + const _DialogBtn({ + required this.label, + required this.pt, + required this.onTap, + this.primary = false, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + height: 36, + decoration: BoxDecoration( + color: primary ? pt.accent : pt.bg3, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + label, + style: TextStyle( + color: primary ? pt.accentFg : pt.fgMuted, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 16fd588..0d677d6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" cli_config: dependency: transitive description: @@ -97,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -153,8 +169,16 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" - crypto: + cross_file: dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -193,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + desktop_drop: + dependency: "direct main" + description: + name: desktop_drop + sha256: d55a010fe46c8e8fcff4ea4b451a9ff84a162217bdb3b2a0aa1479776205e15d + url: "https://pub.dev" + source: hosted + version: "0.4.4" device_info_plus: dependency: transitive description: @@ -241,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" fixnum: dependency: transitive description: @@ -254,6 +294,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" flutter_lints: dependency: "direct dev" description: @@ -267,6 +315,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_riverpod: dependency: "direct main" description: @@ -331,6 +387,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" + url: "https://pub.dev" + source: hosted + version: "2.2.4" flutter_test: dependency: "direct dev" description: flutter @@ -541,6 +605,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + lottie: + dependency: "direct main" + description: + name: lottie + sha256: "8b6359a7422167014aa73ce763fa133fb832065dcc0ac4d1dec1f603a5cef7d0" + url: "https://pub.dev" + source: hosted + version: "3.3.3" markdown: dependency: transitive description: @@ -573,6 +645,70 @@ packages: url: "https://pub.dev" source: hosted version: "6.2.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: "813858c3fe84eb46679eb698695f60665e2bfbef757766fac4d2e683f926e15a" + url: "https://pub.dev" + source: hosted + version: "1.3.1" meta: dependency: transitive description: @@ -621,6 +757,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + open_file_plus: + dependency: "direct main" + description: + name: open_file_plus + sha256: "01ad7011d46d253aa22e5f0bc7345ca49cfe652c50ae9b0af945491c1728a8c3" + url: "https://pub.dev" + source: hosted + version: "3.4.1+1" package_config: dependency: transitive description: @@ -629,6 +773,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: "direct main" description: @@ -637,6 +797,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -710,7 +878,7 @@ packages: source: hosted version: "2.1.8" pointycastle: - dependency: transitive + dependency: "direct main" description: name: pointycastle sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" @@ -733,6 +901,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + pretty_qr_code: + dependency: "direct main" + description: + name: pretty_qr_code + sha256: "474f8a4512113fba06f14a6ec9bbf42353b4e651d7a520e3096f2a9b6bbe7a8a" + url: "https://pub.dev" + source: hosted + version: "3.6.0" protobuf: dependency: transitive description: @@ -749,6 +925,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: "direct main" + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" random_string: dependency: transitive description: @@ -773,6 +957,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "287ea1f667c0b93cdc127dccc707158e2d81ee59fba0459c31a0c7da4d09c755" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + screen_brightness_android: + dependency: transitive + description: + name: screen_brightness_android + sha256: d34f5321abd03bc3474f4c381f53d189117eba0b039eac1916aa92cca5fd0a96 + url: "https://pub.dev" + source: hosted + version: "2.1.3" + screen_brightness_platform_interface: + dependency: transitive + description: + name: screen_brightness_platform_interface + sha256: "737bd47b57746bc4291cab1b8a5843ee881af499514881b0247ec77447ee769c" + url: "https://pub.dev" + source: hosted + version: "2.1.0" sdp_transform: dependency: transitive description: @@ -874,6 +1082,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" sqflite_common: dependency: transitive description: @@ -890,6 +1114,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.7+1" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" sqlcipher_flutter_libs: dependency: "direct main" description: @@ -978,6 +1218,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" unorm_dart: dependency: transitive description: @@ -986,6 +1234,78 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.2" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: @@ -994,6 +1314,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373" + url: "https://pub.dev" + source: hosted + version: "1.1.21" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" + url: "https://pub.dev" + source: hosted + version: "1.2.0" vector_math: dependency: transitive description: @@ -1018,6 +1362,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + volume_controller: + dependency: transitive + description: + name: volume_controller + sha256: ce25d4e02dd6f0b6d5d02dc1daa8de11b2d42d0f5afab8b1eda6aa0ebab3937a + url: "https://pub.dev" + source: hosted + version: "3.4.4" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "14b2e5b9e35c2631e656913c47adecdd71633ae92896a27a64c8f1fcfabc21cc" + url: "https://pub.dev" + source: hosted + version: "1.5.0" watcher: dependency: transitive description: @@ -1093,4 +1461,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.1 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.41.0" diff --git a/pubspec.yaml b/pubspec.yaml index 95a3ec2..b29aae1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: matrix: ^6.2.0 flutter_vodozemac: ^0.5.0 flutter_secure_storage: ^10.0.0 + sqflite: ^2.4.2 sqflite_common_ffi: ^2.3.7+1 sqlcipher_flutter_libs: ^0.6.8 @@ -33,6 +34,14 @@ dependencies: # UI dynamic_color: ^1.8.1 google_fonts: ^6.2.1 + flutter_svg: ^2.0.17 + lottie: ^3.3.2 + pretty_qr_code: ^3.6.0 + qr: ^3.0.2 + + # Cryptography (for E2EE key export/import) + crypto: ^3.0.3 + pointycastle: ^4.0.0 # Utilities shared_preferences: ^2.5.5 @@ -42,11 +51,24 @@ dependencies: http: ^1.6.0 mime: ^2.0.0 collection: ^1.18.0 + url_launcher: ^6.3.0 + package_info_plus: ^8.1.0 + + # File handling + file_picker: ^8.1.7 + desktop_drop: ^0.4.4 + open_file_plus: ^3.4.1 + + # Media playback + media_kit: ^1.1.11 + media_kit_video: ^1.2.5 + media_kit_libs_video: ^1.0.5 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + flutter_launcher_icons: ^0.13.1 dependency_overrides: webcrypto: