diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..2c9f0ef --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "python -c \"import json,sys,datetime; d=json.load(sys.stdin); f=d.get('tool_input',{}).get('file_path','unknown'); t=d.get('tool_name','Edit'); ts=datetime.datetime.now().strftime('%Y-%m-%d %H:%M'); open('CHANGES.md','a').write(f'{ts} [{t}] {f}\\n')\" 2>/dev/null || true", + "statusMessage": "Dokumentation wird aktualisiert..." + } + ] + } + ] + } +} diff --git a/.claude/worktrees/reverent-wilson-b7c43a b/.claude/worktrees/reverent-wilson-b7c43a new file mode 160000 index 0000000..c9af913 --- /dev/null +++ b/.claude/worktrees/reverent-wilson-b7c43a @@ -0,0 +1 @@ +Subproject commit c9af9134680ec43fc8df9c7eb2b428a558941eff diff --git a/.gitignore b/.gitignore index 3820a95..e26b0dd 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +autopilot.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fc2d36e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +## [0.1.33+34] – 2026-06-11 + +### Anrufe & Sprache +- Anruf-Steuerung neu platziert: eigenes "Dock" über dem Konto-Balken (Discord-Stil) statt darin eingebettet; das doppelte Mini-Panel im Profilbereich wurde entfernt. +- Alle Anrufe – auch Sprachkanäle – nutzen jetzt das vollwertige Mini-Call-Widget mit Mikrofon, Deafen, Kamera und Bildschirmübertragung. +- Ein-/Ausblenden des Call-Docks animiert (220 ms). +- Querformat-Optimierung: Stream-Vorschau schrumpft bei wenig Bildschirmhöhe (140 -> 84 px), damit die Raumliste nutzbar bleibt. +- Ausgabelautstärke-Regler (0–150 %) in "Sprache & Video", wirkt sofort auf laufende Anrufe. +- Geräteauswahl verdrahtet: Mikrofon/Lautsprecher/Kamera aus den Einstellungen werden jetzt tatsächlich auf Anrufe angewendet (vorher wurden die Einstellungen nirgends gelesen). +- Ehrliche Fehlermeldung, wenn Windows-Audiotreiber keine Geräteliste an WebRTC melden (bekannter libwebrtc-Bug; Anrufe laufen trotzdem über das Standardgerät). +- Bildschirmübertragungs-Picker passt sich kleinen Bildschirmen an. + +### Chat & Medien +- Bilder werden nicht mehr abgeschnitten: Anpassung an die Chatbreite, kein Verzerren oder Hochskalieren mehr. +- SVG-Inline-Vorschau im Chat (vorher als generische Datei oder fehlerhaft). +- Video-Ladeplatzhalter passen sich der Breite an (keine festen 360 px). +- Lesebestätigungen verbessert: goldener Doppelhaken, Zusammenführung von globalen und Thread-Bestätigungen, korrektes Verhalten wenn die andere Person antwortet/liest. +- Drag-&-Drop-Overlay animiert (Scale + Fade + wippendes Icon, Akzent-Glow). +- Anhang-Dialog öffnet mit Scale-Animation. +- Tastatur-Zwischenablage (Android): frisch kopierte Bilder erscheinen in der Tastatur-Vorschlagsleiste und landen im Anhang-Dialog. +- Banner-Wechsel: Absturz ("setState returned a Future") und Doppelanzeige behoben. + +### Profile & DMs +- Aufklappbarer DM-Header: Klick auf Avatar/Name klappt das Profil auf – Banner als Hintergrund, Avatar skaliert mit (32 -> 96 px), Name wächst, Handle + Biographie blenden ein. +- Profilbild-Bug beim schnellen DM-Wechsel behoben (kein Übersprechen mehr zwischen Kanälen). +- Profil-Einstellungen laden sofort aus dem Cache, Server-Daten parallel im Hintergrund. +- Persistenter Bild-Cache: Avatare und Banner sind nach Neustart sofort da statt nachzuladen. + +### Spaces & Berechtigungen +- Selbst-Aussperr-Schutz: Berechtigungsänderungen werden gegen den echten Server-Stand gemischt (nie mehr den lokalen Cache überschreiben). Zusätzlich bricht Pyramid jede Änderung ab, die den eigenen Account aussperren würde. +- Space-Banner überleben jetzt App-Neustarts (importantStateEvents). +- Aussagekräftige Fehlerdiagnose bei abgelehnten Space-Änderungen. + +### Präsenz / Online-Status +- Online wird nur noch angezeigt, wenn die letzte Aktivität < 5 Min. her ist (behebt "alle wirken online"). +- Präsenz-Heartbeat von 10 s auf 55 s reduziert (weniger Netzwerk/Akku). + +### Teilen +- "Teilen nach Pyramid" (Android): Text und Dateien aus anderen Apps teilen, mit Raum-Picker (Suche + Vorschau). + +### Performance & Build +- Inter-Schrift als Asset gebündelt (google_fonts entfernt) – offline-fähig, schnellerer Start. +- Bilder laden zuerst als Thumbnail (spart Bandbreite, v. a. mobil). +- 12 tote Quelldateien und ungenutzte Abhängigkeit dynamic_color entfernt. +- Android-Release-Build: eigener Release-Keystore, R8/Minify + Resource-Shrinking, ProGuard-Keep-Regeln für WebRTC/LiveKit/Notifications. + +### Infrastruktur (Server) +- Defekter Space "Just us serious" repariert: sauber dupliziert (gleicher Name/Topic/Avatar/Banner, alle 6 Kanäle, Admin-Rechte wiederhergestellt). + +### Hinweis zur Verteilung +- Wegen des neuen Release-Keystores müssen bestehende (Debug-signierte) Android-Installationen einmalig deinstalliert und neu installiert werden – danach laufen Updates normal. diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..73d02f2 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,1080 @@ +2026-04-19 19:18 [Write] C:\Users\nordm\pyramid\.claude\settings.json +2026-04-19 19:18 [Edit] C:\Users\nordm\.claude\settings.json +2026-04-21 03:44 [Edit] C:\Users\nordm\pyramid\lib\layout\app_shell.dart +2026-04-21 03:44 [Edit] C:\Users\nordm\pyramid\lib\features\rooms\rooms_panel.dart +2026-04-21 03:44 [Edit] C:\Users\nordm\pyramid\lib\widgets\hover_region.dart +2026-04-21 03:44 [Edit] C:\Users\nordm\pyramid\lib\features\chat\chat_view.dart +2026-04-21 03:45 [Edit] C:\Users\nordm\pyramid\lib\features\call\voice_channel.dart +2026-04-21 03:45 [Edit] C:\Users\nordm\pyramid\lib\features\call\voice_channel.dart +2026-04-21 03:45 [Edit] C:\Users\nordm\pyramid\lib\features\chat\chat_composer.dart +2026-04-21 03:45 [Edit] C:\Users\nordm\pyramid\lib\features\chat\chat_composer.dart +2026-04-21 03:46 [Edit] C:\Users\nordm\pyramid\lib\features\chat\chat_composer.dart +2026-04-21 05:04 [Edit] C:\Users\nordm\pyramid\lib\core\matrix_client.dart +2026-04-21 05:09 [Edit] C:\Users\nordm\pyramid\lib\core\matrix_client.dart +2026-04-21 05:13 [Edit] C:\Users\nordm\pyramid\pubspec.yaml +2026-04-21 05:14 [Edit] C:\Users\nordm\pyramid\lib\core\matrix_client.dart +2026-04-24 05:33 [Write] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\MEMORY.md +2026-04-24 05:33 [Write] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\project_overview.md +2026-04-24 05:33 [Write] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\infrastructure.md +2026-04-24 05:33 [Write] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\call_system_status.md +2026-04-24 05:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_token.dart +2026-04-24 05:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-24 05:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-24 05:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-24 05:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-24 05:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 05:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 05:43 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 05:43 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 05:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 05:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 05:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-04-24 05:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-04-24 05:56 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-04-24 05:56 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-04-24 05:56 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart +2026-04-24 06:13 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-04-24 06:13 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-04-24 06:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-04-24 06:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-04-24 07:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-24 07:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-24 07:41 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 07:41 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 07:41 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 07:41 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 07:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-24 07:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 07:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 07:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 08:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-24 08:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-24 08:19 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 08:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart +2026-04-24 08:20 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 08:20 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 08:20 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 10:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-04-24 10:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 10:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 10:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 10:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 10:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 10:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 10:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 10:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 10:50 [Write] C:\Users\nordm\pyramid - Kopie\PROTOKOLL.md +2026-04-24 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-04-24 12:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 12:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-04-24 12:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-24 14:04 [Write] C:\Users\nordm\pyramid - Kopie\PROTOKOLL.md +2026-04-24 14:17 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\pyramid_loader.dart +2026-04-24 14:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 14:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 14:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 14:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 14:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 14:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-04-24 14:19 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-04-24 14:24 [Write] C:\Users\nordm\pyramid - Kopie\flutter_launcher_icons.yaml +2026-04-24 14:25 [Write] C:\Users\nordm\pyramid - Kopie\flutter_launcher_icons.yaml +2026-04-24 14:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-24 14:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-04-24 14:35 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\pyramid_loader.dart +2026-04-24 14:40 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\pyramid_loader.dart +2026-04-24 14:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-24 14:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-24 15:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\pyramid_loader.dart +2026-04-24 15:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app.dart +2026-04-24 15:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-24 15:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-24 15:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-24 15:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-24 15:43 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-24 15:43 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-24 16:05 [Write] C:\Users\nordm\pyramid - Kopie\PROTOKOLL.md +2026-04-24 16:29 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-04-24 16:29 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_viewer.dart +2026-04-24 16:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 16:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 16:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 16:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 16:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 16:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 16:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 16:40 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 16:40 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 16:40 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 16:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-24 16:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-24 16:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-24 16:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-24 16:43 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-24 16:43 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-24 16:46 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-24 17:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 17:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 17:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 17:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 17:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 17:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 17:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 17:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-24 17:24 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-04-24 17:24 [Edit] C:\Users\nordm\pyramid - Kopie\lib\main.dart +2026-04-24 17:25 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_player.dart +2026-04-24 17:25 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 17:26 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 17:26 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 17:26 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-24 17:26 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_viewer.dart +2026-04-25 01:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_viewer.dart +2026-04-25 01:46 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_player.dart +2026-04-25 01:46 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_player.dart +2026-04-25 01:46 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_player.dart +2026-04-25 01:46 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_player.dart +2026-04-25 01:46 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_player.dart +2026-04-25 01:46 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 01:46 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 01:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 01:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 01:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 01:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 01:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 01:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 01:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 01:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 01:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 02:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_player.dart +2026-04-25 02:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_player.dart +2026-04-25 02:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_player.dart +2026-04-25 02:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_player.dart +2026-04-25 02:41 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 02:41 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 02:41 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 02:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 02:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 02:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 02:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 02:43 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 02:43 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 02:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart +2026-04-25 02:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\main.dart +2026-04-25 04:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 04:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 04:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 04:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 04:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-25 04:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-25 04:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_provider.dart +2026-04-25 04:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_provider.dart +2026-04-25 04:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-04-25 04:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-04-25 04:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-04-25 04:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-04-25 04:49 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 04:49 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 04:49 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 08:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 08:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 08:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 08:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-25 08:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-25 13:00 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\settings_prefs.dart +2026-04-25 13:00 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app.dart +2026-04-25 13:00 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app.dart +2026-04-25 13:00 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app.dart +2026-04-25 13:00 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app.dart +2026-04-25 13:04 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 13:09 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 13:09 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 13:09 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 13:09 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 13:09 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 13:10 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 13:10 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 13:10 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 13:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 13:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 13:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 13:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 13:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 13:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 13:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 13:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 13:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 13:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 14:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 14:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 14:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 14:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\mxc_image.dart +2026-04-25 14:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 14:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-25 14:40 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 14:40 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-25 14:40 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:49 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:49 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:49 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:49 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:49 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:50 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 14:50 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 15:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 15:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 15:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 15:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 15:08 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 15:08 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 15:08 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 15:08 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:29 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-04-25 18:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:50 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-04-25 18:50 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart +2026-04-25 18:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 18:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 18:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 18:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 18:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 18:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-25 18:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 19:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 19:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 19:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 19:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 19:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 19:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 19:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 19:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 19:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 19:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 19:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-25 19:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-26 04:02 [Edit] C:\Users\nordm\pyramid - Kopie\PROTOKOLL.md +2026-04-26 04:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-26 04:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-26 04:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-26 04:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-26 04:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-26 04:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-26 04:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-26 04:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-26 04:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-26 04:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-26 04:37 [Edit] C:\Users\nordm\pyramid - Kopie\PROTOKOLL.md +2026-04-26 04:45 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\create_join_dialog.dart +2026-04-26 04:54 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\new_dm_dialog.dart +2026-04-26 04:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-04-26 04:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-04-26 04:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-04-26 04:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-04-26 04:57 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-26 04:58 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\create_join_dialog.dart + +## 2026-04-26 — Create/Join/Discover + Neue DM + Einladungen + +### Neue Dateien +- `lib/widgets/new_dm_dialog.dart` — Nutzer-Suche (searchUserDirectory), DM starten (startDirectChat) +- `lib/widgets/create_join_dialog.dart` — Raum/Space erstellen, beitreten, entdecken (Pagination, SpaceEditDialog, RoomSettingsDialog) + +### Geänderte Dateien +- `lib/features/spaces/spaces_rail.dart` — _AddButton → CreateJoinDialog; _SpaceItem Rechtsklick/Longpress → SpaceEditDialog +- `lib/features/rooms/rooms_panel.dart` — Header-⌄-Menü (kontextabhängig); _SectionHeader "+" aktiv; _RoomItem: DM schließen, Nutzer einladen, Raum-Settings; Invite-Karten (Annehmen/Ablehnen) +- `lib/widgets/create_join_dialog.dart` — flutter/material `hide Visibility` (Namenskonflikt Matrix SDK) +2026-04-26 05:00 [Edit] C:\Users\nordm\pyramid - Kopie\CHANGES.md +2026-04-26 05:22 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart +2026-04-26 05:22 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-04-26 05:25 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-26 05:29 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 15:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 15:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 15:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 15:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 15:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 15:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 15:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 15:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 15:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 15:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 15:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 15:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 15:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 15:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\create_join_dialog.dart +2026-04-27 15:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\create_join_dialog.dart +2026-04-27 15:20 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\create_join_dialog.dart +2026-04-27 15:20 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\create_join_dialog.dart +2026-04-27 15:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_provider.dart +2026-04-27 15:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_provider.dart +2026-04-27 15:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_provider.dart +2026-04-27 18:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-27 18:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-27 18:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-27 18:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-27 18:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-27 18:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-27 18:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart +2026-04-27 18:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:56 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:56 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 18:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-27 19:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 19:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 19:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 19:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 19:04 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-27 19:12 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\mxc_image.dart +2026-04-27 19:12 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-27 19:12 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 19:13 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 19:13 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 19:13 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 19:13 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-27 19:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 19:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 19:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 19:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-27 19:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-04-27 19:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\profile_popover.dart +2026-04-27 19:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\profile_popover.dart +2026-04-27 19:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\profile_popover.dart +2026-04-27 19:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\profile_popover.dart +2026-04-27 19:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\profile_popover.dart +2026-04-28 10:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 10:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart +2026-04-28 10:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart +2026-04-28 10:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-28 10:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-28 10:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-28 10:04 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-28 10:04 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-28 10:04 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\banner_crop_dialog.dart +2026-04-28 10:04 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-28 10:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-28 10:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-28 10:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-28 10:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\profile_popover.dart +2026-04-28 10:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\profile_popover.dart +2026-04-28 10:10 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\banner_crop_dialog.dart +2026-04-28 10:11 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-28 10:11 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-28 10:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-28 10:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-28 10:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-28 10:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-28 10:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-28 10:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart +2026-04-28 10:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\banner_crop_dialog.dart +2026-04-28 10:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-28 10:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-28 10:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-28 10:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 10:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_input.dart +2026-04-28 10:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-28 10:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-28 10:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 10:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 10:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 10:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-04-28 10:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-28 10:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-28 10:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-28 10:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-28 10:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-28 10:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-28 10:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-28 10:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-04-28 10:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_input.dart +2026-04-28 10:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_input.dart +2026-04-28 10:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_input.dart +2026-04-28 10:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_input.dart +2026-04-28 11:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-04-28 11:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_input.dart +2026-04-28 15:00 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-04-28 15:01 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-04-28 15:01 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_banner.dart +2026-04-28 15:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 15:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 15:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 15:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-28 15:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-28 15:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-28 15:04 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_banner.dart +2026-04-28 15:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-28 15:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_input.dart +2026-04-28 15:08 [Write] C:\Users\nordm\pyramid - Kopie\docs\feature-checklist.md +2026-04-28 15:15 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\infrastructure.md +2026-04-28 15:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-04-28 15:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-04-28 15:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-04-28 15:30 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-04-28 15:30 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-04-28 15:31 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-04-28 15:31 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\res\xml\file_paths.xml +2026-04-28 15:31 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-04-28 15:32 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-04-28 15:32 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_banner.dart +2026-04-28 15:32 [Write] C:\Users\nordm\pyramid - Kopie\.git\hooks\post-commit +2026-04-28 15:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-04-28 15:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-04-28 15:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-04-28 15:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-04-28 15:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_banner.dart +2026-04-28 15:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_banner.dart +2026-04-28 15:35 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_banner.dart +2026-04-28 15:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-04-28 15:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-04-28 15:42 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-04-28 15:42 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-04-28 15:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-04-28 15:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-04-28 15:43 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_input.dart +2026-04-28 21:54 [Write] C:\Users\nordm\pyramid - Kopie\scripts\release.ps1 +2026-04-28 22:00 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-04-28 22:00 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-04-28 22:00 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\res\drawable\ic_notification.xml +2026-04-28 22:01 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 22:01 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 22:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 22:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 22:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 22:05 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-04-28 22:05 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 22:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 22:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 22:27 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-04-28 22:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 22:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 22:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 22:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 22:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 22:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-28 22:33 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\build.gradle.kts +2026-04-28 22:33 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\build.gradle.kts +2026-04-28 22:50 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\res\drawable\ic_launcher_foreground.xml +2026-04-28 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-28 22:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-28 22:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-28 22:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-28 22:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-28 22:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-28 22:52 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\search_modal.dart +2026-04-28 22:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\search_modal.dart +2026-04-28 23:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 23:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-28 23:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-28 23:04 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\search_modal.dart +2026-04-29 09:46 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 09:46 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 09:46 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\search_modal.dart +2026-04-29 10:04 [Write] C:\Users\nordm\pyramid - Kopie\docs\feature-checklist.md +2026-04-29 10:11 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-29 10:11 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 10:11 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 10:12 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_composer.dart +2026-04-29 10:12 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_composer.dart +2026-04-29 10:12 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 10:12 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 10:12 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 10:13 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 10:13 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-29 10:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 10:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 10:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 10:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 10:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 10:15 [Edit] C:\Users\nordm\pyramid - Kopie\docs\feature-checklist.md +2026-04-29 10:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 10:58 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 10:58 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 10:58 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 10:58 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 10:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 10:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-29 10:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-29 10:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 10:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 11:00 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 11:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 11:03 [Edit] C:\Users\nordm\pyramid - Kopie\docs\feature-checklist.md +2026-04-29 11:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart +2026-04-29 14:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-04-29 14:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-29 14:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 14:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 14:57 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_viewer.dart +2026-04-29 15:00 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_viewer.dart +2026-04-29 15:00 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 15:27 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-04-29 15:28 [Write] C:\Users\nordm\pyramid - Kopie\lib\utils\clipboard_image.dart +2026-04-29 15:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 15:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 15:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 15:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 15:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\utils\gif_favorite_service.dart +2026-04-29 15:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\gif_sticker_picker.dart +2026-04-29 15:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\gif_sticker_picker.dart +2026-04-29 15:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\attachment_dialog.dart +2026-04-29 15:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\attachment_dialog.dart +2026-04-29 15:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\attachment_dialog.dart +2026-04-29 15:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_composer.dart +2026-04-29 15:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_composer.dart +2026-04-29 15:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_composer.dart +2026-04-29 15:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_composer.dart +2026-04-29 19:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 19:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 19:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 19:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 19:58 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 19:58 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 19:58 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\gif_sticker_picker.dart +2026-04-29 19:58 [Write] C:\Users\nordm\bin\pyramid-release +2026-04-29 20:25 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 20:25 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 20:25 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 20:25 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 20:26 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 20:26 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 20:26 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 20:26 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 20:26 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 20:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 20:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 20:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 20:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 20:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\room_list_item.dart +2026-04-29 20:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\room_list_item.dart +2026-04-29 20:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-04-29 20:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 20:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 20:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-29 20:35 [Edit] C:\Users\nordm\pyramid - Kopie\scripts\release.ps1 +2026-04-29 20:41 [Edit] C:\Users\nordm\pyramid - Kopie\scripts\release.ps1 +2026-04-29 20:45 [Edit] C:\Users\nordm\pyramid - Kopie\scripts\release.ps1 +2026-04-29 20:47 [Edit] C:\Users\nordm\pyramid - Kopie\scripts\release.ps1 +2026-04-30 10:44 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\res\drawable\ic_launcher_foreground.xml +2026-04-30 10:44 [Edit] C:\Users\nordm\pyramid - Kopie\flutter_launcher_icons.yaml +2026-04-30 10:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_viewer.dart +2026-04-30 10:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_viewer.dart +2026-04-30 10:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_viewer.dart +2026-04-30 10:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_viewer.dart +2026-04-30 10:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_viewer.dart +2026-04-30 10:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-04-30 10:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_input.dart +2026-04-30 10:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_input.dart +2026-04-30 10:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_input.dart +2026-04-30 10:45 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-04-30 10:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-04-30 11:53 [Edit] C:\Users\nordm\pyramid - Kopie\android\settings.gradle.kts +2026-04-30 11:54 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\build.gradle.kts +2026-04-30 11:54 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-04-30 11:54 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-04-30 11:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\main.dart +2026-04-30 11:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\main.dart +2026-04-30 11:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-30 11:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-30 11:58 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-04-30 12:11 [Edit] C:\Users\nordm\pyramid - Kopie\android\settings.gradle.kts +2026-04-30 12:27 [Edit] C:\Users\nordm\pyramid - Kopie\scripts\release.ps1 +2026-04-30 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\main.dart +2026-04-30 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-04-30 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\auth\login_notifier.dart +2026-04-30 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\auth\login_notifier.dart +2026-04-30 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\auth\login_notifier.dart +2026-04-30 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-30 19:12 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-04-30 19:12 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-30 19:13 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-30 19:13 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-30 19:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-04-30 19:25 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\res\drawable\ic_launcher_foreground.xml +2026-04-30 23:30 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-04-30 23:30 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-04-30 23:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-30 23:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-04-30 23:31 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-04-30 23:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-30 23:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-04-30 23:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-05-01 00:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-05-01 00:04 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\PushService.kt +2026-05-01 00:04 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-05-01 00:04 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-05-01 00:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-05-01 00:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-05-01 00:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-05-01 00:08 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\build.gradle.kts +2026-05-01 11:28 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\res\drawable\ic_launcher_foreground.xml +2026-05-01 11:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 11:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 11:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 11:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 11:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-05-01 11:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-05-01 11:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-05-01 11:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-05-01 11:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\main.dart +2026-05-01 11:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\main.dart +2026-05-01 11:33 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\PushService.kt +2026-05-01 11:51 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\NotificationActionReceiver.kt +2026-05-01 11:51 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-05-01 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-05-01 12:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-05-01 12:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-05-01 13:21 [Edit] C:\Users\nordm\pyramid - Kopie\flutter_launcher_icons.yaml +2026-05-01 13:22 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-05-01 13:22 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app.dart +2026-05-01 13:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 13:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 13:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 13:23 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\NotificationActionReceiver.kt +2026-05-01 13:23 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\NotificationActionReceiver.kt +2026-05-01 13:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-05-01 13:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-05-01 13:53 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\res\drawable\ic_launcher_foreground.xml +2026-05-01 13:53 [Edit] C:\Users\nordm\pyramid - Kopie\flutter_launcher_icons.yaml +2026-05-01 13:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 13:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app.dart +2026-05-01 13:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app.dart +2026-05-01 15:32 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\res\drawable\ic_launcher_foreground.xml +2026-05-01 15:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-05-01 15:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-05-01 15:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-05-01 15:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-05-01 15:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-05-01 15:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-05-01 15:37 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-05-01 15:51 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-01 15:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 15:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 15:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-05-01 15:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-05-01 15:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-05-01 17:00 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\PushService.kt +2026-05-01 17:00 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\NotificationActionReceiver.kt +2026-05-01 17:00 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\NotificationActionReceiver.kt +2026-05-01 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-05-01 17:02 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-05-01 17:13 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 17:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\main.dart +2026-05-01 17:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 17:14 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 17:14 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-05-01 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 20:34 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 20:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-05-01 20:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-05-01 20:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-05-01 20:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\update_download_dialog.dart +2026-05-01 20:34 [Edit] C:\Users\nordm\pyramid - Kopie\flutter_launcher_icons.yaml +2026-05-01 20:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\main.dart +2026-05-01 20:54 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 20:54 [Edit] C:\Users\nordm\pyramid - Kopie\flutter_launcher_icons.yaml +2026-05-01 20:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 20:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 21:07 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\res\drawable\ic_launcher_foreground.xml +2026-05-01 21:10 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 21:10 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 21:11 [Edit] C:\Users\nordm\pyramid - Kopie\flutter_launcher_icons.yaml +2026-05-01 21:12 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 21:12 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-05-01 21:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-01 21:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-05-01 21:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-05-01 21:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-05-01 21:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 21:57 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 21:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 21:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-01 21:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-02 10:21 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-02 10:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-02 10:26 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 10:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-05-02 10:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-05-02 10:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-05-02 10:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 10:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 10:28 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-05-02 11:22 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-02 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 12:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 20:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 20:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 20:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 20:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 20:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 20:08 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 20:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 20:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-02 20:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-02 20:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 20:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 20:51 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-02 20:51 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-02 20:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 21:00 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-02 21:00 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 21:00 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-02 21:17 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-02 21:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-02 21:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-02 21:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-02 21:58 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-03 12:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-03 12:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-03 12:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-03 12:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-03 12:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-03 12:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-03 12:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-03 12:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-03 12:19 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-03 13:21 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-03 13:21 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-03 13:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-03 13:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-03 13:27 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-03 13:27 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-03 13:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-03 13:33 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\res\values\styles.xml +2026-05-03 13:34 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\res\values-night\styles.xml +2026-05-03 13:34 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-03 14:23 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\ReplyReceiver.kt +2026-05-03 14:32 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\NotificationHelper.kt +2026-05-03 14:33 [Write] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\PushService.kt +2026-05-03 14:33 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-05-03 14:33 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-05-03 14:33 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-03 14:33 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-03 14:33 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-03 14:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-03 15:00 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-03 15:00 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-05-04 16:43 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\ReplyReceiver.kt +2026-05-04 16:43 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\PushService.kt +2026-05-04 16:44 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\PushService.kt +2026-05-04 16:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-04 16:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-04 16:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-04 16:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-04 16:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app.dart +2026-05-04 16:45 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app.dart +2026-05-04 16:46 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-04 16:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-04 16:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-05-04 17:04 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-05-04 17:04 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-04 17:04 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-04 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\fcm_push_service.dart +2026-05-04 17:20 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-05-04 17:21 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-05-04 17:32 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\NotificationHelper.kt +2026-05-09 18:35 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-09 18:35 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-09 18:36 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-05-09 19:09 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\README.md +2026-05-09 19:10 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\01-auth.md +2026-05-09 20:00 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\02-rooms.md +2026-05-09 20:02 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\03-messages.md +2026-05-09 20:03 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\04-dm.md +2026-05-09 20:03 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\05-media.md +2026-05-09 20:04 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\06-encryption.md +2026-05-09 20:05 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\07-push.md +2026-05-09 20:06 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\08-voip.md +2026-05-09 20:07 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\09-profile.md +2026-05-09 20:08 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\10-room-admin.md +2026-05-09 20:09 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\11-search.md +2026-05-09 20:10 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\12-quick-reference.md +2026-05-09 20:59 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\08b-livekit-streaming.md +2026-05-09 21:02 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\CHECKLIST.md +2026-05-09 21:02 [Edit] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\README.md +2026-05-09 21:13 [Write] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\13-server-admin-continuwuity.md +2026-05-09 21:14 [Edit] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\10-room-admin.md +2026-05-09 21:14 [Edit] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\10-room-admin.md +2026-05-09 21:14 [Edit] C:\Users\nordm\pyramid - Kopie\docs\matrix-sdk\README.md +2026-05-31 07:20 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\infrastructure.md +2026-05-31 07:20 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\infrastructure.md +2026-05-31 07:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-05-31 07:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-05-31 07:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-05-31 07:51 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-05-31 07:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-05-31 08:39 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_player.dart +2026-05-31 08:46 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-05-31 08:48 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 08:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 08:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 08:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 08:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 08:49 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-05-31 08:50 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 08:50 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 08:50 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:06 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 09:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 09:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 09:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 09:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 09:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 09:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 10:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 10:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 10:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 11:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 11:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 11:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 11:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 11:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 11:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 11:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 11:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 11:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 12:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 12:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 12:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 12:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 12:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 12:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 12:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\document_viewer.dart +2026-05-31 13:22 [Write] C:\Users\nordm\pyramid - Kopie\docs\server-incident-2026-05-31.md +2026-05-31 13:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 13:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 13:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-05-31 13:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-06-05 16:41 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-06-05 16:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-06-05 16:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-06-05 16:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-06-05 16:42 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 16:43 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 16:43 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 16:43 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 16:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 16:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart +2026-06-05 17:11 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:11 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:11 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-05 17:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-06-05 17:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\spaces_rail.dart +2026-06-05 18:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\settings_prefs.dart +2026-06-05 18:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart +2026-06-05 18:07 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-11 12:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-06-11 12:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-06-11 12:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-06-11 12:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-06-11 12:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\media_player.dart +2026-06-11 12:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-06-11 12:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-06-11 12:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\user_panel.dart +2026-06-11 12:56 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-11 12:56 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart +2026-06-11 12:56 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart +2026-06-11 12:56 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart +2026-06-11 12:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\screen_share_picker.dart +2026-06-11 12:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-06-11 12:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-06-11 12:57 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-06-11 13:01 [Edit] C:\Users\nordm\pyramid - Kopie\analysis_options.yaml +2026-06-11 13:02 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\call_system_status.md +2026-06-11 13:03 [Write] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\codebase_cleanup_2026-06.md +2026-06-11 13:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart +2026-06-11 13:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-11 13:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\members\members_panel.dart +2026-06-11 13:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\profile_popover.dart +2026-06-11 13:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_composer.dart +2026-06-11 13:30 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_composer.dart +2026-06-11 13:31 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart +2026-06-11 13:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\mxc_image.dart +2026-06-11 13:31 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\mxc_image.dart +2026-06-11 13:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 13:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 13:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\attachment_dialog.dart +2026-06-11 13:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 13:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\message_group.dart +2026-06-11 13:36 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-06-11 13:36 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-06-11 13:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\theme.dart +2026-06-11 13:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\theme.dart +2026-06-11 13:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\theme.dart +2026-06-11 13:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-06-11 13:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-06-11 13:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-06-11 13:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-06-11 13:42 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\codebase_cleanup_2026-06.md +2026-06-11 13:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-06-11 13:52 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-06-11 13:53 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-06-11 13:54 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\AndroidManifest.xml +2026-06-11 13:54 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-06-11 13:55 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-06-11 13:55 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-06-11 13:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart +2026-06-11 13:56 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-06-11 13:56 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\notification_service.dart +2026-06-11 13:57 [Write] C:\Users\nordm\pyramid - Kopie\lib\widgets\share_target_dialog.dart +2026-06-11 13:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-06-11 13:57 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-06-11 13:58 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 13:58 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 13:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 13:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 13:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 13:59 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 14:00 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 14:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-06-11 14:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-06-11 14:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-06-11 14:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-06-11 14:02 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-06-11 14:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-06-11 14:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-06-11 14:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-06-11 14:03 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-06-11 14:04 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\codebase_cleanup_2026-06.md +2026-06-11 14:08 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-06-11 14:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-06-11 14:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-06-11 14:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-06-11 14:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 14:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 14:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 14:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 14:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 14:40 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\codebase_cleanup_2026-06.md +2026-06-11 17:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 17:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-06-11 17:32 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-06-11 17:35 [Write] C:\Users\nordm\AppData\Local\Temp\devtest_webrtc\lib\main.dart +2026-06-11 17:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-06-11 17:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-06-11 17:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-06-11 17:41 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\codebase_cleanup_2026-06.md +2026-06-11 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-06-11 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-06-11 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-06-11 17:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 17:55 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 17:56 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart +2026-06-11 17:57 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\codebase_cleanup_2026-06.md +2026-06-11 18:13 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\infrastructure.md +2026-06-11 18:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\spaces\space_admin_dialog.dart +2026-06-11 18:20 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\build.gradle.kts +2026-06-11 18:20 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\build.gradle.kts +2026-06-11 18:21 [Write] C:\Users\nordm\pyramid - Kopie\android\app\proguard-rules.pro +2026-06-11 18:24 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\mxc_image.dart +2026-06-11 18:24 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\codebase_cleanup_2026-06.md +2026-06-11 18:32 [Edit] C:\Users\nordm\pyramid - Kopie\android\app\src\main\kotlin\chat\pyramid\pyramid\MainActivity.kt +2026-06-11 18:33 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-06-11 18:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-06-11 18:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-06-11 18:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-06-11 18:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-06-11 18:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\update_checker.dart +2026-06-11 18:34 [Edit] C:\Users\nordm\pyramid - Kopie\scripts\release.ps1 +2026-06-11 18:35 [Edit] C:\Users\nordm\pyramid - Kopie\scripts\release.ps1 +2026-06-11 18:38 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\codebase_cleanup_2026-06.md +2026-06-11 18:50 [Write] C:\Users\nordm\pyramid - Kopie\CHANGELOG.md +2026-06-11 23:26 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-11 23:26 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-06-11 23:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart +2026-06-11 23:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-06-11 23:27 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-06-11 23:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-06-11 23:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-06-11 23:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-06-11 23:28 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-06-11 23:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-11 23:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-11 23:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-11 23:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-11 23:29 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart +2026-06-11 23:44 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\ice_servers.dart +2026-06-11 23:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-06-11 23:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\voip_manager.dart +2026-06-11 23:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-06-11 23:44 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\livekit_call_manager.dart +2026-06-11 23:48 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\call_system_status.md +2026-06-11 23:48 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\infrastructure.md +2026-06-11 23:48 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\MEMORY.md +2026-06-12 00:03 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\call_system_status.md +2026-06-12 00:10 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-06-12 00:10 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-06-12 00:10 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart +2026-06-12 00:11 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\infrastructure.md +2026-06-12 00:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\create_join_dialog.dart +2026-06-12 00:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart +2026-06-12 00:35 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\call_system_status.md +2026-06-12 00:36 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\infrastructure.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5607106 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# Pyramid – Arbeitsregeln für Claude + +Pyramid ist Bernds eigener Matrix-Client (Flutter, kein FluffyChat-Fork). Bernd ist +**kein Entwickler** – er gibt Ziele und Prioritäten vor, Claude trifft die technischen +Entscheidungen und erklärt sie in einfachem Deutsch. Die App ist bei echten Nutzern im +Einsatz (u. a. Uta) – Stabilität und Datensicherheit gehen vor Feature-Tempo. + +**Dies ist das einzige aktuelle Repo:** `C:\Users\nordm\pyramid - Kopie\`. +(`C:\Users\nordm\pyramid` und `C:\Users\nordm\MatrixPi\pyramid` sind veraltete +April-Schnappschüsse – dort NIE arbeiten.) + +## Infrastruktur + +- Homeserver: Continuwuity auf dem Pi5 (`steggi-matrix.work`) +- LiveKit/TURN: Hetzner „Leuchtturm" → `wss://livekit.steggi-matrix.work` (API-Key LKMatrixPi), coturn 94.130.78.116:3478 +- Git-Remote: Gitea auf dem Pi (`http://192.168.178.71:3000/steggi/pyramid.git`) – Push = Backup +- Push-Benachrichtigungen: FCM + Sygnal auf dem Pi (Memory „Pyramid Push" lesen, bevor daran gearbeitet wird) +- Vollbackup vom 2026-07-03: `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz` + +## Arbeitszyklus (IMMER einhalten) + +Jede Session – egal ob frisch gestartet oder fortgesetzt – läuft so: + +1. **Einlesen:** `PROGRESS.md` (letzter Stand, Stolperfallen) und `ROADMAP.md` + (nächster offener Punkt) lesen. NIE Arbeit doppelt machen, die dort als erledigt steht. +2. **Einen Punkt nehmen:** den obersten nicht abgehakten Punkt des aktuellen Meilensteins + aus `ROADMAP.md` – nicht mehrere gleichzeitig, keine Sprünge in spätere Meilensteine. +3. **Umsetzen & prüfen:** Nach der Änderung mindestens `flutter analyze` sauber bekommen; + wo sinnvoll `flutter test` und `flutter run -d windows` (schnellster Praxistest). +4. **Protokollieren:** `PROGRESS.md` aktualisieren (Format siehe dort) und den Punkt in + `ROADMAP.md` abhaken. +5. **Commit + Push:** kleiner `git commit` (`feat:`/`fix:`/`refactor:`/`chore:`) und + `git push origin master` (Gitea = Backup). Lieber 5 kleine Commits als ein riesiger. +6. Weiter mit dem nächsten Punkt, solange Kontingent/Zeit da ist. + +Wird eine Session mitten in einem Punkt abgebrochen (Limit erreicht), MUSS der letzte +Eintrag in `PROGRESS.md` den Zwischenstand beschreiben: was halb fertig ist, welche +Dateien angefasst wurden, was der nächste konkrete Handgriff ist. + +## Qualitätsleitplanken + +- **Kein Aussperren, kein Datenverlust:** Alles rund um Login, Sessions, Verschlüsselung + und Key-Backup ist heilig. Der Uta-Random-Logout-Bug zeigt: Fehlerpfade dürfen NIE in + einem stillen Logout enden. Änderungen daran immer mit Test: Login → Nachrichten → + Logout → erneuter Login → alte verschlüsselte Nachrichten noch lesbar? +- **Nach dem Refactoring (M2) gilt:** keine Verhaltensänderung ohne Not – Refactoring + heißt gleiche Funktion, bessere Struktur. Nach jedem Refactoring-Schritt App starten + und Kernflows prüfen (Login, Raum öffnen, Nachricht senden, Voice-Channel beitreten). +- **Keine Secrets ins Repo:** Tokens, Recovery-Keys, Passwörter, Keystore-Dateien + niemals committen (Signing-Keys liegen in `Documents\pyramid_keys_20260425.txt`). +- **Modularität (Bernd wichtig!):** Die Architektur muss aus austauschbaren Bausteinen + bestehen – jedes Feature (Calls, Streaming, Push, Chat-Timeline, …) hinter einer klaren, + schmalen Schnittstelle, sodass man einen alten Baustein durch einen neuen ersetzen kann, + ohne den Rest anzufassen. Beim Refactoring (M2) ist das DAS Leitprinzip; bei jedem neuen + Feature fragen: „Könnte man dieses Modul in einem Jahr komplett neu schreiben, ohne + andere Module zu ändern?" +- **Abhängigkeiten:** neue Pakete nur mit gutem Grund, etabliert und gepflegt. +- **Umgebung beachten:** Auf dem Pi (SSH/tmux-Sessions) ist evtl. kein Flutter-Toolchain + verfügbar. Dann trotzdem sauber arbeiten, aber jeden Schritt in PROGRESS.md als + „UNGETESTET (Pi)" markieren – der nächste PC-Lauf holt `flutter analyze` + Praxistest nach. +- **Bernd fragen** nur bei echten Richtungsentscheidungen (UX-Geschmack, Priorität) – + Fragen in PROGRESS.md unter „Fragen an Bernd" sammeln statt die Session zu blockieren. + +## Nützliches Wissen + +- Discord ist die UX-Referenz für Calls/Voice-Channels (Join/Leave, Nutzer-Lautstärke, + Geräteauswahl, Noise Suppression, Streaming-Qualitätswahl). +- `CHANGES.md` ist ein automatisches Änderungslog (Hook) – nicht von Hand pflegen. +- Windows-Build ist der schnellste Testweg; Android-Release über das Release-Skript + (siehe Commit d706ace), signiert – Play-Protect-Warnung siehe ROADMAP M6. diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..9260ca6 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,33 @@ +# Pyramid – Arbeitsprotokoll + +Neueste Einträge OBEN. Jede Claude-Session schreibt hier nach jedem abgeschlossenen +Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format: + +``` +## JJJJ-MM-TT – Kurztitel +**Erledigt:** was fertig ist (mit Commit-Hash) +**Offen/Nächster Schritt:** der konkrete nächste Handgriff +**Stolperfallen:** was schiefging und wie es gelöst wurde (damit es nie zweimal passiert) +**Fragen an Bernd:** (optional) gesammelte Richtungsfragen, die nicht blockieren +``` + +--- + +## 2026-07-03 – Arbeitsstruktur angelegt + Vollbackup (Setup, noch kein Code) + +**Erledigt:** +- Vollbackup inkl. Git-Historie: `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz` (510 MB) +- `CLAUDE.md` (Arbeitsregeln), `ROADMAP.md` (M0–M7 nach Bernds Prioritäten), dieses Protokoll +- Klargestellt: `C:\Users\nordm\pyramid - Kopie` ist das EINZIGE aktuelle Repo; + `C:\Users\nordm\pyramid` und `MatrixPi\pyramid` sind veraltete April-Stände (Commit c9af913) + +**Offen/Nächster Schritt:** M0, Punkt 2 – die ~140 uncommitteten Dateien mit `git status` +sichten und in thematische Commits aufteilen (diese drei neuen Dateien mit committen), +dann `git push origin master`. + +**Stolperfallen:** +- Letzter Commit ist vom 2026-04-28 (d706ace), gearbeitet wurde aber bis mindestens + 2026-06-12 → zwischen Commits und Realität liegen 6 Wochen. Genau deshalb: ab jetzt + kleine Commits nach jedem Schritt. +- Git-Remote (Gitea auf dem Pi) enthält Zugangsdaten in der URL – funktioniert, aber + nur erreichbar, wenn der Pi läuft (unterwegs: WireGuard nötig). diff --git a/PROTOKOLL.md b/PROTOKOLL.md new file mode 100644 index 0000000..200a643 --- /dev/null +++ b/PROTOKOLL.md @@ -0,0 +1,386 @@ +# Pyramid – Entwicklungsprotokoll +Stand: 24.04.2026 + +--- + +## Projektübersicht + +**Pyramid** ist ein Flutter-basierter Matrix-Client, modelliert nach einem FluffyChat-Fork, +mit zwei parallelen Call-Systemen: + +| System | Einsatz | Datei | +|--------|---------|-------| +| **LiveKit** (`livekit_client`) | Gruppen-Sprachräume | `lib/core/livekit_call_manager.dart` | +| **Matrix VoIP** (`matrix` SDK) | 1:1 Direktnachrichten | `lib/core/voip_manager.dart` | + +Server-Infrastruktur läuft auf einem Raspberry Pi (`steggi-matrix.work`), LiveKit unter +`livekit.steggi-matrix.work`. + +--- + +## Erledigte Änderungen + +### 1. LiveKit – Qualitäts-Bugs behoben (`livekit_call_manager.dart`) + +**Problem:** Streamer und Zuschauer konnten die Übertragungsqualität nicht ändern. + +**Ursachen:** +- `_screenShareEncoding()` hatte einen hardcodierten `switch ('balanced')` — gab immer + halbe Bitrate zurück, unabhängig vom eingestellten Qualitäts-Key. +- `setSubscribeQuality('auto')` sprang vorzeitig raus und setzte die Qualität nie. + +**Fix:** +```dart +VideoEncoding _screenShareEncoding() { + return _screenShareEncodings[_localQualityKey] ?? + const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15); +} +``` +`setSubscribeQuality` wendet jetzt immer die Qualität an, auch bei 'auto'. + +--- + +### 2. LiveKit – Track-Events lösen UI-Rebuild aus (`livekit_call_manager.dart`) + +**Problem:** Kamera-Feed wurde nach dem Publishen nicht angezeigt, weil kein Widget-Rebuild ausgelöst wurde. + +**Fix:** Handler für alle Track-Events hinzugefügt: +```dart +..on((_) => notifyListeners()) +..on((_) => notifyListeners()) +..on((_) => notifyListeners()) +..on((_) => notifyListeners()) +..on((_) => notifyListeners()) +..on((_) => notifyListeners()) +..on((_) => notifyListeners()) +``` + +--- + +### 3. LiveKit – Optimistic Mute (`livekit_call_manager.dart`) + +**Problem:** Mute-Button hatte eine spürbare Verzögerung (async API-Call). + +**Fix:** `notifyListeners()` wird jetzt vor dem `await` aufgerufen: +```dart +Future toggleMute() async { + isMuted = !isMuted; + notifyListeners(); // Sofort + await _room?.localParticipant?.setMicrophoneEnabled(!isMuted); +} +``` + +--- + +### 4. VoIP – Kamera-Fix (`voip_manager.dart`) + +**Problem:** Kamera-Tracks wurden nicht korrekt zugewiesen, weil der Renderer bereits belegt +wurde bevor Video-Daten verfügbar waren. + +**Fix:** +- Track-Zuweisung erfolgt jetzt erst wenn `getVideoTracks().isNotEmpty` — kein Renderer-Update + auf leere Streams mehr. +- `_isCameraMuted` wird bei eingehenden Calls korrekt aus dem Call-Typ gesetzt: + `_isCameraMuted = call.type == CallType.kVoice;` +- Bei `kConnected`-State wird `setLocalVideoMuted(false)` nochmals aufgerufen falls Kamera + aktiv sein soll. +- Poll-Intervall von 1500ms auf 1000ms reduziert für schnellere Feed-Erkennung. + +--- + +### 5. VoIP – Optimistic Mic/Camera Toggle (`voip_manager.dart`) + +**Problem:** `toggleMic()` und `toggleCamera()` nutzten `safeAction()` mit 1500ms Delay. + +**Fix:** +- `toggleMic()` flippt State sofort, reverted bei Fehler automatisch. +- `toggleCamera()` flippt `_isCameraMuted` sofort (optimistic), ruft `_updateRenderers()` + mehrfach nach kurzen Delays auf. + +--- + +### 6. VoIP – Desktop Speakerphone Fix (`voip_manager.dart`) + +**Problem:** `MissingPluginException: No implementation found for method enableSpeakerphone` +auf Windows/Desktop. + +**Fix:** Speakerphone-Callback wird im Konstruktor unterdrückt: +```dart +try { + (voip as dynamic).onConfigSpeakerphone = (bool _) async {}; +} catch (_) {} +``` + +--- + +### 7. SQLite-Stabilität auf Windows + +**Problem:** Sporadische "Database is locked"-Fehler durch parallele Zugriffe. + +**Fix:** WAL-Modus aktiviert und `busy_timeout` gesetzt, um Locking-Konflikte zu verhindern. + +--- + +### 8. Einheitliches Call-UI (`voice_channel.dart`) – Kompletter Rewrite + +Die gesamte `voice_channel.dart` wurde neu strukturiert mit einer einheitlichen Architektur +für LiveKit und VoIP. + +#### 8a. Unified Participant Model + +```dart +class _ParticipantData { + final String id, name; + final Widget? videoWidget; + final bool isSpeaking, isMuted, hasVideo, isScreenShare; + final Color avatarColor; + final dynamic originalParticipant; +} +``` + +#### 8b. Adaptive Seitenverhältnisse (`voice_channel.dart`) + +**Problem:** Video-Tiles waren auf `AspectRatio(16/9)` festgelegt — Hochkant-Videos vom +Handy wurden stark beschnitten und falsch dargestellt. + +**Fix:** +- `_ParticipantData` bekommt ein `aspectRatio`-Feld (Default `16.0/9.0`). +- VoIP: echte Dimension aus `RTCVideoRenderer.videoWidth/videoHeight` ausgelesen. +- LiveKit: echte Dimension aus `(videoTrack as dynamic).currentDimensions` ausgelesen. +- Alle Renderer nutzen `VideoViewFit.contain` / `RTCVideoViewObjectFitContain` statt `cover` + — kein Beschnitt mehr. +- `_ParticipantGrid` wechselt von `GridView(childAspectRatio: 16/9)` zu `Wrap` mit + individuell berechneter `SizedBox(width, height)` pro Tile. +- `_ParticipantStrip` passt Tile-Breite an: `101 * p.aspectRatio` für Landscape-Tiles. + +#### 8c. Hero/Spotlight-System + +- **Auto-Spotlight:** Screenshares werden automatisch als Hero angezeigt +- **Manuelles Pinnen:** Klick auf Tile → wird Hero; erneuter Klick = unpin +- Gear-Icon im Hero-Overlay öffnet das Qualitätsmenü (hover-reveal) +- Fullscreen-Button öffnet eine eigene Fullscreen-Seite via `Navigator.push` + +#### 8d. Aktive Sprecher + +Pulsierende Rahmen-Animation (`AnimationController`) für Teilnehmer, die gerade sprechen. + +#### 8e. Teilnehmer-Sortierung + +``` +Screensharer → Kamera-an → Sprechend → Audio-only +``` + +#### 8f. Responsive Layouts + +**Portrait:** Hero oben (Expanded) + horizontaler Teilnehmer-Strip unten (120px) +**Landscape:** Hero links (Expanded) + vertikale Sidebar rechts (200px) + +Beide Layouts sind **einklappbar** (Swipe-Geste + Pfeil-Button). + +#### 8g. Zentrales Mirroring + +`_MirrorWrapper` spiegelt nur die eigene Kamera (`isLocal && !isScreenShare`). +Screenshares und Remote-Teilnehmer bleiben ungespiegelt. + +#### 8h. Grid-Layout mit einheitlichen Tiles + +`SliverGridDelegateWithFixedCrossAxisCount`: +- < 600px → 2 Spalten +- 600–900px → 3 Spalten +- > 900px → 4 Spalten + +#### 8i. Steuerungs-Reihenfolge (alle Views) + +``` +[ Mic ] [ Deaf ] [ Kamera ] [ Screen ] | [ Auflegen ] +``` + +--- + +### 9. Mini-Call-Widget – Steuerungs-Reihenfolge (`mini_call_widget.dart`) + +Gleiche Reihenfolge wie Haupt-Call-Ansicht: Mic → Deaf → Kamera → Screen. + +--- + +### 10. 4K Support + +4K (2160p) als Qualitätsoption in LiveKit- und VoIP-Qualitätsmenüs hinzugefügt. + +--- + +### 11. App-Icon – Pyramid-Logo (`flutter_launcher_icons.yaml`) + +**Problem:** App zeigte das Flutter-Standard-Icon statt dem Pyramid-Logo. + +**Fix:** +- `flutter_launcher_icons.yaml` auf `assets/logo.png` (goldene 3D-Pyramide, 1024px) umgestellt. +- macOS-Generierung explizit aktiviert (`macos: generate: true`). +- Windows ICO nach `dart run flutter_launcher_icons` mit Pillow neu generiert + (16/32/48/64/128/256px multi-size ICO, verhindert das 48px-Limit des Packages). + +--- + +### 12. Lade-Animation – 3D Pyramiden-Painter (`pyramid_loader.dart`) + +**Problem:** Lottie-JSON zeigte inkorrekte Farben, SVG wurde nur statisch gerendert +(flutter_svg führt kein JavaScript aus). + +**Lösung:** JavaScript-Animations-Logik aus dem Original-SVG exakt nach Dart portiert +als `CustomPainter`: + +- `_ease(t, exp)`: kubische Ease-In/Out wie im JS-Original +- `_yawAt(t)`: Yaw-Rotation mit Hold-Phase (80% Rotation, 20% Pause bei π/4) +- `_PyramidPainter`: 4 Flächen, Y-Rotation, Z-Clipping, orthografische Projektion, + 20°-Tilt, Depth-Sorting (Painter's Algorithm) +- Fill: `darken(accent, 0.45)`, Stroke: `accent`, strokeWidth: `1.4 * (size/96)` +- `useLottie: false` ist nun der Standard — kein Lottie-Asset mehr nötig + +**Loader Preview** in den Appearance Settings (zwei 120px-Boxen: Custom Painter + Lottie-Fallback). + +--- + +### 13. Chat-Scroll-Stabilisierung (`chat_view.dart`) + +**Problem:** Der Chat zuckte und zitterte je weiter man nach oben scrollte, besonders +wenn neue History nachgeladen wurde. + +**Ursache:** `DateDivider` und `MessageGroup` waren in einem gemeinsamen `Column`-Widget +mit einem einzigen Key zusammengefasst. Wenn beim Nachladen von History die `showDate`- +Eigenschaft einer Gruppe sich änderte (z.B. weil sie nicht mehr die älteste Gruppe war), +änderte sich die Höhe des Items — direkt in dem Bereich, den der User gerade betrachtete. + +**Fix:** +- `DateDivider` und `MessageGroup` sind jetzt separate List-Items mit unabhängigen Keys: + - Gruppen: `ValueKey('g:')` + - Datumstrennlinien: `ValueKey('d:')` +- `findChildIndexCallback` nutzt eine vorberechnete `Map` für O(1)-Lookup + statt O(n)-`indexWhere`. +- 200ms Cooldown nach `requestHistory` verhindert sofortige Re-Trigger durch veraltetes + `maxScrollExtent`. + +--- + +### 14. Startup-Flash Fix (`app.dart`) + +**Problem:** Beim App-Start flackerte kurz die Server-Eingabe-Seite auf, bevor der +korrekte Redirect zu `/app` stattfand. + +**Ursache:** `isLoggedInProvider` (StreamProvider) gibt beim ersten Build `AsyncLoading` +zurück. `valueOrNull ?? false` ergibt `false` → Router leitet sofort zu `/server` weiter. +Kurz danach resolvet der Stream und der Router erstellt sich neu mit `isLoggedIn = true`. + +**Fix:** `PyramidApp` prüft `isLoggedInProvider` zuerst. Solange noch `isLoading`: +vollbild `PyramidLoader` statt Router — die Pyramiden-Animation überbrückt den kurzen +Moment, bis der Auth-State bekannt ist. + +--- + +### 15. Eigenes Profilbild (`user_panel.dart`) + +**Problem:** Das eigene Profilbild wurde unten links (UserPanel) nicht angezeigt — +nur ein Farbverlauf-Platzhalter mit Initial-Buchstabe. + +**Fix:** +- `_ownProfileProvider` (FutureProvider): ruft einmalig `client.getProfileFromUserId(userID)` + auf und cached das Ergebnis. +- `UserPanel` zeigt nun `MxcAvatar` mit dem echten Profilbild. +- Anzeigename kommt aus `profile.displayName` statt nur aus der UserID. +- Farbverlauf-Initial-Avatar bleibt als Fallback (`_InitialAvatar`) während des Ladens + oder wenn kein Bild gesetzt ist. + +--- + +### 16. QR-Code-Verifikation (`app_shell.dart`, `settings_modal.dart`) + +**Problem:** Geräteverifikation unterstützte nur Emoji-SAS — kein QR-Code-Flow. + +**Fix:** +- `KeyVerificationMethod.qrShow` zu `verificationMethods` in `matrix_client.dart` hinzugefügt. +- `pubspec.yaml`: `pretty_qr_code: ^3.6.0` und `qr: ^3.0.2` ergänzt. +- Eingehender Verifizierungsdialog (`app_shell.dart`) und ausgehender Dialog (`settings_modal.dart`) + unterstützen jetzt beide 3 neue States: + - `askChoice` → Methodenwahl (Emoji oder QR) + - `confirmQRScan` → QR-Code anzeigen (`QrCode.fromUint8List` + `PrettyQrView`) + "Gescannt bestätigen"-Button + - `showQRSuccess` → Erfolgsanzeige + +**Technischer Hinweis:** Matrix QR-Codes sind Binärdaten (`qrDataRawBytes: Uint8Buffer`). +Rendering über `QrCode.fromUint8List(data: rawBytes, errorCorrectLevel: QrErrorCorrectLevel.L)`. + +--- + +### 17. Wiederherstellungsschlüssel ändern (`settings_modal.dart`) + +**Problem:** Kein Feature zum Wechseln des SSSS-Wiederherstellungsschlüssels vorhanden. + +**Implementierung:** `_ChangeRecoveryKeyDialog` mit Bootstrap-State-Machine: + +**Flow:** +1. **Laden** — Bootstrap startet, `wipeSsss(true)` → `newSsss()` generiert neuen Schlüssel +2. **Schlüssel anzeigen** — Schlüssel selektierbar + Kopier-Button; wartet auf Nutzer +3. **Bestätigen** — Nutzer tippt Schlüssel zur Bestätigung ein (`_ConfirmKeyBody`) +4. **Aktivieren** — wartet auf Bootstrap `done` (Cross-Signing + Key Backup im Hintergrund) +5. **Fertig** — Erfolgsmeldung + +**Kritischer Fix (BootstrapBadStateException):** +- `newSsss()` ruft intern `checkCrossSigning()` auf → State geht sofort zu `askWipeCrossSigning`. +- `openExistingSsss()` ist nach `newSsss()` nicht mehr erreichbar — wurde entfernt. +- Alle Post-`newSsss`-States werden ohne `_step`-Guards auto-advanced. +- Bootstrap-`done` setzt `_step` nur wenn bereits in `applying` — verhindert vorzeitigen Dialog-Schluss. + +--- + +## Offene Punkte + +### ⚠️ Screenshare auf Windows (LiveKit) + +`startScreenShare()` wählt auf Windows automatisch die erste verfügbare Screen-Source. +Der `ScreenSharePicker` ist implementiert, muss noch verifiziert werden. + +### ℹ️ Profilbilder in Chats – Lazy Loading + +Profilbilder in der Raumliste und im Chat werden erst beim Öffnen des jeweiligen +Raums geladen (kein Preloading). Ist akzeptables Verhalten, kein Fehler. + +### 📋 Noch nicht implementiert (Settings) + +~~Alle erledigt in Sitzung 3 (26.04.2026): Gerät umbenennen, Alle anderen Sitzungen abmelden, Ed25519-Fingerabdruck, Blockierte Nutzer, Passwort ändern, Konto löschen, Logout-Button~~ + +### 💡 Nice-to-have + +- **QR-Express-Login** (MSC4108): Desktop zeigt QR → Handy scannt → eingeloggt + verifiziert. + Voraussetzung: Rendezvous-Server + SDK-Unterstützung (matrix-dart-sdk ≥ 7.x erwartet). + Aktuell blockiert durch fehlende SDK-API in 6.2.0. + +--- + +## Geänderte Dateien (vollständig) + +| Datei | Inhalt | +|-------|--------| +| `lib/core/livekit_call_manager.dart` | Quality-Bug-Fix, Track-Events, Optimistic Mute, 4K | +| `lib/core/voip_manager.dart` | Kamera-Fix, Optimistic Toggle, Speakerphone-Fix, 4K | +| `lib/features/call/voice_channel.dart` | Kompletter Rewrite: Unified Architecture, adaptive AR, Hero, Mirroring, Grid, Sortierung, Controls | +| `lib/features/call/mini_call_widget.dart` | Steuerungs-Reihenfolge | +| `lib/core/matrix_client.dart` | WAL-Modus, busy_timeout, qrShow in verificationMethods | +| `lib/widgets/pyramid_loader.dart` | 3D CustomPainter (JS-Port), useLottie=false Standard | +| `lib/core/app.dart` | Startup-Flash-Fix: PyramidLoader während isLoggedInProvider lädt | +| `lib/features/chat/chat_view.dart` | Scroll-Stabilisierung: flache Item-Liste, O(1)-Lookup, Cooldown | +| `lib/features/rooms/user_panel.dart` | Eigenes Profilbild via _ownProfileProvider + MxcAvatar | +| `lib/widgets/settings_modal.dart` | QR-Verifikation, Wiederherstellungsschlüssel ändern, Loader-Preview | +| `lib/layout/app_shell.dart` | QR-Verifikation im eingehenden Verifizierungsdialog | +| `pubspec.yaml` | pretty_qr_code, qr hinzugefügt | +| `flutter_launcher_icons.yaml` | Pyramid-Logo für alle Plattformen | +| `windows/runner/resources/app_icon.ico` | Multi-Size ICO (16/32/48/64/128/256px) via Pillow | +| `assets/logo.png` | Pyramid-Logo (1024px) | +| `assets/pyramid-loader.json` | Lottie-Fallback (aus Ordner mit Leerzeichen verschoben) | + +--- + +## Backups + +| Datum | Pfad | +|-------|------| +| 24.04.2026 (Sitzung 1) | `E:\pyramid_backup_20260424` | +| 24.04.2026 (Sitzung 2) | `E:\pyramid_backup_20260424_2` | +| 26.04.2026 | `E:\pyramid_backup_20260426` | diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..0b58b4b --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,96 @@ +# Pyramid – Roadmap + +Die lange Liste. Wird von oben nach unten abgearbeitet (Arbeitszyklus siehe `CLAUDE.md`). +Reihenfolge = Priorität: erst Ordnung & Sicherheit (Datenverlust-Risiken!), dann das +große Refactoring, dann Calls/Streaming/Chat-Features, dann Feinschliff. +Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert. + +## M0 – Backup & Ordnung + +- [x] Vollbackup vor dem Refactoring (2026-07-03 → `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz`) +- [ ] Die ~140 uncommitteten Dateien sichten, in thematische Commits aufteilen, zu Gitea pushen +- [ ] `flutter analyze` komplett sauber bekommen und sauber halten +- [ ] `lib/features/call/` vs. `lib/features/calls/` (leer?) klären – Leiche entfernen +- [ ] README.md durch echte Projektbeschreibung ersetzen (was ist Pyramid, wie baut man es, wo liegt was) + +## M1 – Sicherheit: Nie wieder aussperren / kein Datenverlust + +- [ ] **Uta-Random-Logout untersuchen:** Ursache finden (Soft-Logout vom Server? Token + ungültig? Fehlerpfad im Sync, der zum Logout führt?). Erst Logging an allen + Logout-Pfaden einbauen, dann Ursache fixen. Ein unerwarteter Logout darf NIE + Krypto-Schlüssel wegwerfen. +- [ ] Sichere Speicherung von Access-Token & Krypto-DB prüfen (secure storage / sqlcipher korrekt?) +- [ ] Key-Backup einrichten-Flow: Recovery-Key erzeugen, verständlich erklären, zum Sichern zwingen +- [ ] Key-Backup wiederherstellen-Flow: nach Login auf neuem Gerät alte Nachrichten entschlüsseln +- [ ] Logout-Schutz: Warnung mit Klartext-Folgen, wenn Key-Backup fehlt +- [ ] Geräte-/Sessionverwaltung in den Einstellungen (Liste, Verifizieren, Abmelden) +- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar + +## M2 – Das große Refactoring (erst nach M0/M1!) + +Ziel: gleiche Funktionen, saubere **modulare** Struktur – als Fundament für alles Folgende. +Leitprinzip (Bernd): austauschbare Bausteine – jedes Modul muss sich komplett neu schreiben +lassen, ohne dass andere Module angefasst werden müssen. + +- [ ] Ist-Analyse schreiben: Architektur-Schwächen auflisten (Call-Logik verteilt auf + `livekit_call_manager.dart`, `voip_manager.dart`, `voice_channel.dart`?, State-Management + uneinheitlich?, Gott-Dateien?, tote Widgets?) → als Refactoring-Plan in PROGRESS.md +- [ ] Modul-Schnitt definieren: Module (auth, chat, rooms, calls, streaming, push, storage, …) + mit je einer schmalen öffentlichen Schnittstelle; Abhängigkeiten nur über Interfaces, + keine Quer-Importe zwischen Feature-Modulen +- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung / + LiveKit-Verbindung / UI-State sauber getrennt) +- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns) +- [ ] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen +- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit + +## M3 – Calls: Logik, UI & Audio (Discord als Referenz) + +- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger, + klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch +- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht) +- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den + Einstellungen auswählbar machen – geht aktuell gar nicht) +- [ ] Noise Suppression als optionale Einstellung (z. B. RNNoise/LiveKit-Audio-Filter) +- [ ] Lautstärke pro Teilnehmer einstellbar (Discord-Style, persistent pro Nutzer) +- [ ] Weitere Feinjustierung: Eingangsempfindlichkeit/Gate, Echo-Unterdrückung an/aus, + Push-to-Talk prüfen – von Discords Sprach-Einstellungen inspirieren lassen +- [ ] Anruf-Benachrichtigung auch bei gekillter App (Push-Architektur nutzen) + +## M4 – Streaming (Bildschirmübertragung) + +- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings? + Software- statt Hardware-Encoding?), dann gezielt fixen +- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl) +- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen) +- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos + +## M5 – Chat-Funktionen + +- [ ] Sprachnachrichten aufnehmen & abspielen (MSC3245-kompatibel, wie Element) +- [ ] **Bilder speichern → richtige Galerie:** über MediaStore/Downloads statt in den + unzugänglichen App-Ordner (Android Scoped Storage korrekt nutzen) +- [ ] Medien senden/empfangen generell prüfen (Fortschritt, Vorschau, Videos) + +## M6 – Release & Verteilung + +- [ ] **Google-Warnung beim Installieren untersuchen:** Play-Protect-Meldung seit der + Signatur-Einführung analysieren (Signing-Konfig prüfen; unbekannte Signatur + Sideload + löst das oft aus; ggf. Play-Protect-Appeal oder saubere v2/v3-Signierung) +- [ ] Release-Skript prüfen/aktualisieren (Windows + Android), Versionierung sauber +- [ ] In-App-Updater testen (existiert laut Git-Historie) + +## M7 – Design & Feinschliff + +- [ ] Rooms-Icon überarbeiten – passt stilistisch nicht zu den anderen Buttons +- [ ] Design-Konsistenz-Pass über alle Icons/Buttons +- [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich +- [ ] Themes/Darkmode-Feinschliff, Schriftgrößen + +## Ideen-Parkplatz (unsortiert, erst nach M7 bewerten) + +- Threads, Sticker, Standort teilen, mehrere Konten +- Watch-Together (z. B. Jellyfin gemeinsam schauen) + +> Bernd: „Das ist nur der Anfang meiner Liste" – neue Punkte einfach hier in den +> passenden Meilenstein eintragen (oder Claude sagen, er soll sie einsortieren). diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..5a64268 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,6 +9,11 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + # Design-Mockups, kein Produktionscode — nicht analysieren. + - mockup/** + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0f7fbda..165e3e5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,8 +1,19 @@ +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") +} + +// Release signing is configured via android/key.properties (gitignored). +// The keystore lives outside the repo; see key.properties for the path. +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) } } android { @@ -11,6 +22,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -30,13 +42,42 @@ android { versionName = flutter.versionName } - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } } } + + buildTypes { + release { + // Falls back to debug signing on machines without key.properties + // so `flutter run --release` still works there. + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") + // Required to compile PushService.kt which extends FirebaseMessagingService directly. + // Version must stay in sync with FirebaseSDKVersion in firebase_core's gradle.properties. + implementation(platform("com.google.firebase:firebase-bom:33.16.0")) + implementation("com.google.firebase:firebase-messaging-ktx") } flutter { diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..c609036 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "858748942228", + "project_id": "chat-pyramid", + "storage_bucket": "chat-pyramid.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:858748942228:android:661405508fbb2f40d30181", + "android_client_info": { + "package_name": "chat.pyramid.pyramid" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDbqLHYFHqUdPOAexDFTpMvQVkQHY6chFE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..3d16a07 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,18 @@ +# Flutter's own keep rules are added automatically by the Flutter Gradle plugin. + +# WebRTC / LiveKit: native code calls Java classes via JNI by name, +# so nothing under org.webrtc may be stripped or renamed. +-keep class org.webrtc.** { *; } +-dontwarn org.webrtc.** +-keep class livekit.org.webrtc.** { *; } +-dontwarn livekit.org.webrtc.** + +# flutter_local_notifications deserializes scheduled notifications with Gson +# using reflection; stripped type adapters crash on the next reboot/schedule. +-keep class com.dexterous.flutterlocalnotifications.** { *; } +-keepattributes Signature +-keepattributes *Annotation* + +# Flutter references Play Core for deferred components, which this app +# doesn't ship — silence the missing-class errors from R8. +-dontwarn com.google.android.play.core.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2412157..018de07 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ - + + @@ -7,8 +9,13 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/chat/pyramid/pyramid/BgEngine.kt b/android/app/src/main/kotlin/chat/pyramid/pyramid/BgEngine.kt new file mode 100644 index 0000000..d07a45d --- /dev/null +++ b/android/app/src/main/kotlin/chat/pyramid/pyramid/BgEngine.kt @@ -0,0 +1,140 @@ +package chat.pyramid.pyramid + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.plugin.common.MethodChannel +import io.flutter.view.FlutterCallbackInformation + +/** + * Bootstraps a headless Flutter background engine running the + * `notificationEngineMain` Dart entrypoint and forwards work items + * (decrypt-and-show, send-reply) to it via a MethodChannel. + * + * Only used when the app is KILLED (no main FlutterEngine). The Dart side builds + * a short-lived Matrix client against the same DB to decrypt / send. This is safe + * because callers gate on the main app being dead — never two clients at once. + */ +object BgEngine { + private const val TAG = "PYRAMID-BGENGINE" + private const val CHANNEL = "chat.pyramid.pyramid/bg_engine" + + private var engine: FlutterEngine? = null + private var channel: MethodChannel? = null + private var ready = false + private var appContext: Context? = null + // Tasks queued until the Dart handler signals readiness. + private val pending = mutableListOf>>() + // Pending "Neue Nachricht" fallback placeholders, keyed by notifId. Shown + // only if decryption doesn't replace them in time; cancelled by showDecrypted. + private val fallbackHandler = Handler(Looper.getMainLooper()) + private val fallbacks = mutableMapOf() + + /** Schedule a placeholder shown after [delayMs] unless decryption beats it. */ + fun scheduleFallback(context: Context, title: String, roomId: String, notifId: Int, delayMs: Long) { + val ctx = context.applicationContext + appContext = ctx + fallbackHandler.post { + cancelFallback(notifId) + val r = Runnable { + fallbacks.remove(notifId) + NotificationHelper.show(ctx, title, "Neue Nachricht", roomId, notifId) + Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)") + } + fallbacks[notifId] = r + fallbackHandler.postDelayed(r, delayMs) + } + } + + private fun cancelFallback(notifId: Int) { + fallbacks.remove(notifId)?.let { fallbackHandler.removeCallbacks(it) } + } + + /** Enqueue a task; boots the engine on first use. Must run on the main thread. */ + fun run(context: Context, method: String, args: Map) { + appContext = context.applicationContext + Handler(Looper.getMainLooper()).post { + try { + ensureEngine(context.applicationContext) + if (ready) { + channel?.invokeMethod(method, args) + } else { + pending.add(method to args) + } + } catch (e: Exception) { + Log.w(TAG, "run failed: ${e.message}") + } + } + } + + private fun ensureEngine(context: Context) { + if (engine != null) return + + val prefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) + val handle = try { + prefs.getLong("flutter.bg_engine_handle", 0L) + } catch (_: Exception) { + 0L + } + if (handle == 0L) { + Log.w(TAG, "no bg_engine_handle stored — cannot start background engine") + return + } + + // The Flutter native library must be loaded BEFORE looking up the + // callback — nativeLookupCallbackInformation is a native method and + // throws UnsatisfiedLinkError otherwise. + val loader = FlutterInjector.instance().flutterLoader() + loader.startInitialization(context) + loader.ensureInitializationComplete(context, null) + + val cbInfo = FlutterCallbackInformation.lookupCallbackInformation(handle) + if (cbInfo == null) { + Log.w(TAG, "callback info not found for handle $handle") + return + } + + val eng = FlutterEngine(context) + engine = eng + + val ch = MethodChannel(eng.dartExecutor.binaryMessenger, CHANNEL) + channel = ch + ch.setMethodCallHandler { call, result -> + when (call.method) { + "bgEngineReady" -> { + ready = true + Log.d(TAG, "Dart background engine ready — flushing ${pending.size} task(s)") + pending.forEach { (m, a) -> ch.invokeMethod(m, a) } + pending.clear() + result.success(null) + } + // Dart finished decrypting — show the real content using the SAME + // native notification (keeps the reliable MainActivity reply action). + "showDecrypted" -> { + val title = call.argument("title") ?: "" + val body = call.argument("body") ?: "Neue Nachricht" + val roomId = call.argument("room_id") ?: "" + val notifId = call.argument("notif_id") ?: NotificationHelper.stableId(roomId) + val ctx = appContext + if (ctx != null && roomId.isNotEmpty()) { + // Beat the fallback placeholder to it — show decrypted directly. + cancelFallback(notifId) + NotificationHelper.show(ctx, title, body, roomId, notifId) + Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId") + } + result.success(null) + } + else -> result.notImplemented() + } + } + + eng.dartExecutor.executeDartCallback( + DartExecutor.DartCallback(context.assets, loader.findAppBundlePath(), cbInfo) + ) + Log.d(TAG, "background engine started") + } +} diff --git a/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt b/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt index 6310bfa..c9ab274 100644 --- a/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt +++ b/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt @@ -3,49 +3,270 @@ package chat.pyramid.pyramid import android.content.Intent import android.net.Uri import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.util.Log +import androidx.core.app.RemoteInput import androidx.core.content.FileProvider import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.plugin.common.MethodChannel import java.io.File class MainActivity : FlutterActivity() { private val channel = "chat.pyramid.pyramid/install" + // ── Share-Target (Teilen nach Pyramid) ──────────────────────────────────── + // Extracts shared text/streams from SEND/SEND_MULTIPLE intents. Streams are + // copied into cacheDir because Flutter cannot read content:// URIs directly. + private fun extractShare(intent: Intent?): Map? { + if (intent == null) return null + val action = intent.action + if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null + val text = intent.getStringExtra(Intent.EXTRA_TEXT) + val uris = mutableListOf() + if (action == Intent.ACTION_SEND) { + val u: Uri? = if (Build.VERSION.SDK_INT >= 33) + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_STREAM) + if (u != null) uris.add(u) + } else { + val list: ArrayList? = if (Build.VERSION.SDK_INT >= 33) + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + else @Suppress("DEPRECATION") intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + if (list != null) uris.addAll(list) + } + if (text.isNullOrEmpty() && uris.isEmpty()) return null + val paths = uris.mapNotNull { copyShareToCache(it) } + return mapOf("text" to text, "paths" to paths) + } + + private fun copyShareToCache(uri: Uri): String? { + return try { + val name = contentResolver.query(uri, null, null, null, null)?.use { c -> + val idx = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx >= 0 && c.moveToFirst()) c.getString(idx) else null + } ?: "shared_${System.currentTimeMillis()}" + val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_") + val outFile = File(cacheDir, "share_${System.currentTimeMillis()}_$safe") + val stream = contentResolver.openInputStream(uri) ?: return null + stream.use { input -> + outFile.outputStream().use { input.copyTo(it) } + } + outFile.absolutePath + } catch (e: Exception) { + Log.e("PYRAMID-SHARE", "copyShareToCache failed: ${e.message}") + null + } + } + + // Extract reply text from an intent using all known key patterns. + private fun extractReply(intent: Intent?): Pair { + if (intent == null) return Pair(null, null) + // Only treat intents with our REPLY action as reply intents. + if (intent.action != ReplyReceiver.ACTION_REPLY) return Pair(null, null) + val remoteInputResults = RemoteInput.getResultsFromIntent(intent) + val replyText: String? = + remoteInputResults?.let { bundle -> + // Primary key must match RemoteInput.Builder key in NotificationHelper. + bundle.getCharSequence(ReplyReceiver.REMOTE_INPUT_KEY)?.toString()?.trim() + ?: bundle.keySet()?.firstOrNull()?.let { bundle.getCharSequence(it)?.toString()?.trim() } + } + ?: intent.getStringExtra(ReplyReceiver.REMOTE_INPUT_KEY) + ?: intent.getStringExtra("input") + val payload = intent.getStringExtra(ReplyReceiver.EXTRA_ROOM_ID) + ?: intent.getStringExtra("payload") + return Pair(replyText, payload) + } + + override fun onCreate(savedInstanceState: Bundle?) { + // If this Activity was launched to handle a notification reply (cold start), + // apply a fully transparent theme BEFORE super.onCreate so no UI is ever + // drawn. The Flutter engine still initialises (needed for E2EE send via SDK) + // but the window remains invisible. + val (replyText, _) = extractReply(intent) + if (!replyText.isNullOrEmpty()) { + setTheme(R.style.ReplyHandlerTheme) + Log.e("PYRAMID-INTENT", "onCreate: reply launch detected — transparent theme applied") + } + super.onCreate(savedInstanceState) + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + // Cache so ReplyReceiver can call back into Dart without starting a new engine. + FlutterEngineCache.getInstance().put("main", flutterEngine) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel) .setMethodCallHandler { call, result -> - if (call.method == "installApk") { - val path = call.argument("path") - if (path == null) { - result.error("INVALID_ARG", "path is null", null) - return@setMethodCallHandler + when (call.method) { + // Flutter asks for the room_id embedded in the launch intent (cold-start tap). + "getInitialRoomId" -> { + result.success(intent?.getStringExtra("room_id")) } - try { - val file = File(path) - val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - FileProvider.getUriForFile( - this, - "${applicationContext.packageName}.fileprovider", - file - ) + // Cold-start share: app was launched via "Teilen nach Pyramid". + "getInitialShare" -> { + val share = extractShare(intent) + if (share != null) { + result.success(share) + intent = Intent() // clear so it's delivered only once } else { - Uri.fromFile(file) + result.success(null) } - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "application/vnd.android.package-archive") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + // Flutter asks for any inline reply that arrived on cold-start. + // When the app is killed and the user presses Reply, Android calls + // onCreate (not onNewIntent), so the reply text is in the launch intent. + "getInitialReply" -> { + val (replyText, payload) = extractReply(intent) + Log.e("PYRAMID-INTENT", "getInitialReply: replyText=${replyText?.take(20)} payload=$payload") + if (!replyText.isNullOrEmpty() && !payload.isNullOrEmpty()) { + result.success(mapOf("room_id" to payload, "text" to replyText)) + // Clear the launch intent so a second getInitialReply call returns null. + intent = Intent() + } else { + result.success(null) } + } + // Supported CPU ABIs in preference order, so the updater can + // pick the matching split APK from a release. + "getAbis" -> { + result.success(Build.SUPPORTED_ABIS.toList()) + } + "installApk" -> { + val path = call.argument("path") + if (path == null) { + result.error("INVALID_ARG", "path is null", null) + return@setMethodCallHandler + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !packageManager.canRequestPackageInstalls()) { + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:$packageName") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + result.error("PERMISSION_REQUIRED", "install_permission", null) + return@setMethodCallHandler + } + try { + val file = File(path) + val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + FileProvider.getUriForFile( + this, + "${applicationContext.packageName}.fileprovider", + file + ) + } else { + Uri.fromFile(file) + } + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + result.success(null) + } catch (e: Exception) { + result.error("INSTALL_ERROR", e.message, null) + } + } + "openNotificationSettings" -> { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + } + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + } + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) result.success(null) - } catch (e: Exception) { - result.error("INSTALL_ERROR", e.message, null) } - } else { - result.notImplemented() + "openBatterySettings" -> { + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + startActivity(intent) + } catch (e: Exception) { + val fallback = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(fallback) + } + result.success(null) + } + "minimizeApp" -> { + moveTaskToBack(true) + result.success(null) + } + "showNativeNotification" -> { + val title = call.argument("title") ?: "" + val body = call.argument("body") ?: "" + val roomId = call.argument("roomId") ?: "" + val notifId = call.argument("notifId") ?: NotificationHelper.stableId(roomId) + if (roomId.isNotEmpty()) { + NotificationHelper.show(this, title, body, roomId, notifId) + result.success(null) + } else { + result.error("INVALID_ARG", "roomId missing", null) + } + } + else -> result.notImplemented() } } } + + // Called when the app is already running (background) and a notification action + // fires — reply and tap both arrive here via onNewIntent (singleTop + SINGLE_TOP flag). + override fun onNewIntent(intent: Intent) { + // Zero-duration transition for all notification-driven intents. + overridePendingTransition(0, 0) + + super.onNewIntent(intent) + setIntent(intent) + + Log.e("PYRAMID-INTENT", "onNewIntent action=${intent.action}") + + // ── Reply action ────────────────────────────────────────────────────── + if (intent.action == ReplyReceiver.ACTION_REPLY) { + val (replyText, payload) = extractReply(intent) + Log.e("PYRAMID-INTENT", "reply: replyText=${replyText?.take(20)} payload=$payload") + if (!replyText.isNullOrEmpty() && !payload.isNullOrEmpty()) { + flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> + MethodChannel(messenger, channel).invokeMethod( + "replyFromNotification", + mapOf("room_id" to payload, "text" to replyText), + ) + } + } + // Always send back immediately — never show the app UI for a reply. + moveTaskToBack(true) + overridePendingTransition(0, 0) + return + } + + // ── Share action — app already running, user shared from another app ── + val share = extractShare(intent) + if (share != null) { + flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> + MethodChannel(messenger, channel).invokeMethod("sharedContent", share) + } + return + } + + // ── Tap action — open the room ──────────────────────────────────────── + val roomId = intent.getStringExtra("room_id") ?: return + flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> + MethodChannel(messenger, channel).invokeMethod( + "openRoom", + mapOf("room_id" to roomId), + ) + } + } } diff --git a/android/app/src/main/kotlin/chat/pyramid/pyramid/NotificationHelper.kt b/android/app/src/main/kotlin/chat/pyramid/pyramid/NotificationHelper.kt new file mode 100644 index 0000000..c3ea032 --- /dev/null +++ b/android/app/src/main/kotlin/chat/pyramid/pyramid/NotificationHelper.kt @@ -0,0 +1,107 @@ +package chat.pyramid.pyramid + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.RemoteInput + +object NotificationHelper { + const val CHANNEL_ID = "pyramid_messages" + + /** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */ + fun stableId(roomId: String): Int { + var h = 0 + for (c in roomId) h = ((h * 31) + c.code) and 0x7FFFFFFF + return if (h == 0) 1 else h + } + + fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, "Nachrichten", NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Neue Nachrichten" + enableVibration(true) + } + nm(context).createNotificationChannel(channel) + } + } + + fun show(context: Context, title: String, body: String, roomId: String, notifId: Int) { + ensureChannel(context) + + val mutFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_MUTABLE else 0 + + // Reply action — BroadcastReceiver so replying NEVER opens the app. + // ReplyReceiver decrypts/sends via the background engine when killed. + // (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.) + val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY) + .setLabel("Antworten…").build() + val replyIntent = Intent(context, ReplyReceiver::class.java).apply { + action = ReplyReceiver.ACTION_REPLY + putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId) + putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId) + } + val replyPi = PendingIntent.getBroadcast( + context, notifId, + replyIntent, + PendingIntent.FLAG_UPDATE_CURRENT or mutFlag, + ) + val replyAction = NotificationCompat.Action.Builder(0, "Antworten", replyPi) + .addRemoteInput(replyInput) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .build() + + // Dismiss action — also points to ReplyReceiver so notification is cancelled. + val dismissIntent = Intent(context, ReplyReceiver::class.java).apply { + action = ReplyReceiver.ACTION_DISMISS + putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId) + putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId) + } + val dismissPi = PendingIntent.getBroadcast( + context, notifId + 1_000_000, + dismissIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + // Tap action — opens MainActivity to the right room. + val tapIntent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + putExtra("room_id", roomId) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val tapPi = PendingIntent.getActivity( + context, notifId + 2_000_000, + tapIntent, + PendingIntent.FLAG_UPDATE_CURRENT or mutFlag, + ) + + val notif = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setColor(Color.rgb(0x7B, 0x61, 0xFF)) + .setContentTitle(title) + .setContentText(body) + .setPriority(NotificationCompat.PRIORITY_HIGH) + // Only buzz/heads-up on the first show — the decrypted update reuses + // the same notifId and replaces the text silently (no second alert). + .setOnlyAlertOnce(true) + .setContentIntent(tapPi) + .setAutoCancel(true) + .addAction(replyAction) + .addAction(NotificationCompat.Action(0, "Gelesen", dismissPi)) + .setGroup(CHANNEL_ID) + .build() + + nm(context).notify(notifId, notif) + } + + private fun nm(context: Context) = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager +} diff --git a/android/app/src/main/kotlin/chat/pyramid/pyramid/PushService.kt b/android/app/src/main/kotlin/chat/pyramid/pyramid/PushService.kt new file mode 100644 index 0000000..bed947c --- /dev/null +++ b/android/app/src/main/kotlin/chat/pyramid/pyramid/PushService.kt @@ -0,0 +1,160 @@ +package chat.pyramid.pyramid + +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.plugin.common.MethodChannel +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class PushService : FirebaseMessagingService() { + + override fun onMessageReceived(message: RemoteMessage) { + val data = message.data + val roomId = data["room_id"] ?: run { + Log.d(TAG, "no room_id in payload, skipping") + return + } + + val prefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE) + + // Skip if the Dart app is actively syncing (wrote a heartbeat < 20 s ago). + val heartbeat = try { + prefs.getLong("flutter.notif_app_heartbeat", 0L) + .takeIf { it > 0L } + ?: prefs.getInt("flutter.notif_app_heartbeat", 0).toLong() + } catch (_: Exception) { 0L } + if (System.currentTimeMillis() - heartbeat < 20_000L) { + Log.d(TAG, "app heartbeat fresh, skipping FCM notification") + return + } + + val eventId = data["event_id"] + val notifId = NotificationHelper.stableId(roomId) + val hs = (prefs.getString("flutter.notif_homeserver", "") ?: "").trimEnd('/') + val token = (prefs.getString("flutter.notif_access_token", "") ?: "") + + // Fetch sender + room name on a background thread (main thread forbids I/O). + var title = "Pyramid" + if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) { + val latch = CountDownLatch(1) + Thread { + try { title = fetchTitle(hs, token, roomId, eventId) ?: "Pyramid" } + finally { latch.countDown() } + }.start() + latch.await(12L, TimeUnit.SECONDS) + } + + Log.d(TAG, "handling push: title=$title roomId=$roomId") + + val engine = FlutterEngineCache.getInstance().get("main") + if (engine != null) { + // App backgrounded but alive — show placeholder immediately, then the + // warm main client decrypts and updates it (fast, barely visible). + NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId) + if (eventId != null) { + val capturedRoomId = roomId + val capturedEventId = eventId + val capturedNotifId = notifId + Handler(Looper.getMainLooper()).post { + try { + MethodChannel(engine.dartExecutor.binaryMessenger, + "chat.pyramid.pyramid/install") + .invokeMethod("decryptAndUpdateNotification", mapOf( + "room_id" to capturedRoomId, + "event_id" to capturedEventId, + "notif_id" to capturedNotifId, + )) + Log.d(TAG, "decryptAndUpdateNotification invoked (main engine) for $capturedRoomId") + } catch (e: Exception) { + Log.w(TAG, "decryptAndUpdateNotification failed: ${e.message}") + } + } + } + } else if (eventId != null) { + // App killed — decrypt FIRST via the background engine, then show the + // real content directly. The "Neue Nachricht" placeholder is only a + // fallback if decryption takes too long or fails. + Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId") + BgEngine.scheduleFallback(this, title, roomId, notifId, 6_000L) + BgEngine.run(this, "decryptAndShow", mapOf( + "room_id" to roomId, + "event_id" to eventId, + "notif_id" to notifId, + )) + } else { + // No event id to decrypt — just show the placeholder. + NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId) + } + } + + override fun onNewToken(token: String) { + Log.d(TAG, "FCM token refreshed — storing for Dart to re-register pusher") + getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE) + .edit() + .putString("flutter.fcm_pending_token", token) + .apply() + } + + // ─── HTTP helpers ───────────────────────────────────────────────────────── + + private fun fetchTitle( + hs: String, token: String, roomId: String, eventId: String + ): String? { + return try { + val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8") + val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8") + + val event = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/event/$encEvent", token) + ?: return null + + val senderId = event.optString("sender") + var senderName = senderId.split(":").firstOrNull()?.removePrefix("@") ?: "" + + if (senderId.isNotEmpty()) { + val encSender = java.net.URLEncoder.encode(senderId, "UTF-8") + val member = httpGet( + "$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.member/$encSender", token) + val name = member?.optString("displayname") + if (!name.isNullOrEmpty()) senderName = name + } + + val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token) + val roomName = nameState?.optString("name") + + return if (!roomName.isNullOrEmpty()) "$senderName · $roomName" + else senderName.ifEmpty { null } + } catch (e: Exception) { + Log.w(TAG, "fetchTitle failed: ${e.message}") + null + } + } + + private fun httpGet(url: String, token: String): JSONObject? { + var conn: HttpURLConnection? = null + return try { + conn = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("Authorization", "Bearer $token") + connectTimeout = 8_000 + readTimeout = 8_000 + } + if (conn.responseCode != 200) return null + JSONObject(conn.inputStream.bufferedReader().readText()) + } catch (_: Exception) { + null + } finally { + conn?.disconnect() + } + } + + companion object { + private const val TAG = "PYRAMID-PUSH" + } +} diff --git a/android/app/src/main/kotlin/chat/pyramid/pyramid/ReplyReceiver.kt b/android/app/src/main/kotlin/chat/pyramid/pyramid/ReplyReceiver.kt new file mode 100644 index 0000000..8bb786d --- /dev/null +++ b/android/app/src/main/kotlin/chat/pyramid/pyramid/ReplyReceiver.kt @@ -0,0 +1,84 @@ +package chat.pyramid.pyramid + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.app.RemoteInput +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.plugin.common.MethodChannel + +class ReplyReceiver : BroadcastReceiver() { + + companion object { + const val ACTION_REPLY = "chat.pyramid.pyramid.REPLY" + const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS" + const val EXTRA_ROOM_ID = "room_id" + const val EXTRA_NOTIF_ID = "notif_id" + const val REMOTE_INPUT_KEY = "reply_text" + } + + override fun onReceive(context: Context, intent: Intent) { + val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return + val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0) + + // Dismiss the notification immediately. + (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .cancel(notifId) + + if (intent.action == ACTION_DISMISS) { + Log.d("PYRAMID-REPLY", "dismiss action for room $roomId") + return + } + + // Extract inline reply text. Some Samsung/MIUI/OEM devices drop the + // RemoteInput bundle or use a different key — try multiple fallbacks. + val remoteInput = RemoteInput.getResultsFromIntent(intent) + val replyText = remoteInput?.let { bundle -> + bundle.getCharSequence(REMOTE_INPUT_KEY)?.toString()?.trim() + ?: bundle.keySet()?.firstOrNull()?.let { bundle.getCharSequence(it)?.toString()?.trim() } + } ?: intent.getStringExtra(REMOTE_INPUT_KEY) + ?: intent.getStringExtra("input") + ?: intent.getStringExtra("notification_action_input") + ?: intent.getStringExtra("reply_text") + Log.d("PYRAMID-REPLY", "extracted replyText=${replyText?.take(20)} (remoteInput=${remoteInput != null})") + if (replyText.isNullOrEmpty()) { + Log.w("PYRAMID-REPLY", "reply text empty after all fallbacks, aborting") + return + } + + Log.d("PYRAMID-REPLY", "reply received: room=$roomId text=$replyText") + + // Fast path: Flutter engine is alive — forward directly via MethodChannel. + val engine = FlutterEngineCache.getInstance().get("main") + if (engine != null) { + Log.d("PYRAMID-REPLY", "Flutter engine alive → MethodChannel") + try { + MethodChannel(engine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/install") + .invokeMethod("replyFromNotification", + mapOf("room_id" to roomId, "text" to replyText)) + return + } catch (e: Exception) { + Log.w("PYRAMID-REPLY", "MethodChannel failed: ${e.message}, falling back to prefs") + } + } + + // Slow path: app killed — send the encrypted reply via the background + // engine (no Activity, fully silent). _bgSendReply falls back to the + // prefs queue itself if it can't send, so nothing is lost. + // goAsync() keeps this BroadcastReceiver's process alive while the engine + // boots + sends (otherwise it could be killed right after onReceive). + Log.d("PYRAMID-REPLY", "Flutter engine not running → background engine send") + val pending = goAsync() + BgEngine.run(context, "sendReply", mapOf( + "room_id" to roomId, + "text" to replyText, + )) + // Release after a window long enough for sync + encrypted send (~4 s seen + // in logs; 15 s gives margin). The engine completes independently. + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + try { pending.finish() } catch (_: Exception) {} + }, 15_000L) + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2cbfbdb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..d4fac54 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5f349f7 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..aa3bda2 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..b2497e8 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..e003750 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..22ecbf5 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..b2aba57 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be..9894b63 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -15,4 +15,10 @@ + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..4e68469 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #1A1B1E + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef88..35889f1 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -15,4 +15,12 @@ + + diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e4ef43f..02767eb 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ca7fe06..1177961 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -21,6 +21,7 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.11.1" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("com.google.gms.google-services") version "4.4.4" apply false } include(":app") diff --git a/assets/fonts/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/assets/fonts/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter-ExtraBold.ttf b/assets/fonts/Inter-ExtraBold.ttf new file mode 100644 index 0000000..0fdf1e0 Binary files /dev/null and b/assets/fonts/Inter-ExtraBold.ttf differ diff --git a/assets/fonts/Inter-Italic.ttf b/assets/fonts/Inter-Italic.ttf new file mode 100644 index 0000000..e7ed211 Binary files /dev/null and b/assets/fonts/Inter-Italic.ttf differ diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..1193895 Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..289d653 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/notification_badge.png b/assets/notification_badge.png new file mode 100644 index 0000000..5f15c67 Binary files /dev/null and b/assets/notification_badge.png differ diff --git a/assets/pyramid-loader.json b/assets/pyramid-loader.json new file mode 100644 index 0000000..e56fd73 --- /dev/null +++ b/assets/pyramid-loader.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":120,"w":200,"h":200,"nm":"PyramidLoader","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"face_0_slot_3","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[100],"h":1},{"t":2,"s":[100],"h":1},{"t":3,"s":[100],"h":1},{"t":4,"s":[100],"h":1},{"t":5,"s":[100],"h":1},{"t":6,"s":[100],"h":1},{"t":7,"s":[100],"h":1},{"t":8,"s":[100],"h":1},{"t":9,"s":[100],"h":1},{"t":10,"s":[100],"h":1},{"t":11,"s":[100],"h":1},{"t":12,"s":[100],"h":1},{"t":13,"s":[100],"h":1},{"t":14,"s":[100],"h":1},{"t":15,"s":[100],"h":1},{"t":16,"s":[100],"h":1},{"t":17,"s":[100],"h":1},{"t":18,"s":[100],"h":1},{"t":19,"s":[100],"h":1},{"t":20,"s":[100],"h":1},{"t":21,"s":[100],"h":1},{"t":22,"s":[100],"h":1},{"t":23,"s":[100],"h":1},{"t":24,"s":[100],"h":1},{"t":25,"s":[100],"h":1},{"t":26,"s":[100],"h":1},{"t":27,"s":[100],"h":1},{"t":28,"s":[100],"h":1},{"t":29,"s":[100],"h":1},{"t":30,"s":[100],"h":1},{"t":31,"s":[100],"h":1},{"t":32,"s":[100],"h":1},{"t":33,"s":[100],"h":1},{"t":34,"s":[100],"h":1},{"t":35,"s":[100],"h":1},{"t":36,"s":[100],"h":1},{"t":37,"s":[100],"h":1},{"t":38,"s":[100],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014843,171.41663917972903],[25.254606555010795,168.4823104778698]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331856,171.41663917972903],[27.051928158630545,165.11883425295738]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.280660659196656,171.41663917972903],[30.329202572241016,161.70386936171468]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513414,171.41663917972903],[35.23167595892863,158.32327808225233]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.545421763770435,171.41663917972903],[41.867430278900926,155.08151817465492]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112144,171.41663917972903],[50.290594742135035,152.10096254861588]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[60.48276971388455,149.51968659541475]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.021711443009764,171.41663917972903],[72.33361100630755,147.4873228087879]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530236,171.41663917972903],[85.62202576908322,146.15861054812373]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.76383848175135,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.15046474746714,166.49908237253814],[114.37797423091669,146.15861054812373],[114.37797423091669,146.15861054812373]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.035359501747664,161.95417685080264],[127.6663889936925,147.4873228087879],[127.6663889936925,147.4873228087879]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.97759368381941,157.9009504134384],[139.51723028611542,149.51968659541475],[139.51723028611542,149.51968659541475]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.524739673823944,154.41502126880033],[149.7094052578649,152.10096254861585],[149.7094052578649,152.10096254861585]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[52.23930132816764,151.53412935182934],[158.13256972109895,155.08151817465486],[158.13256972109895,155.08151817465486]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[61.71757321027329,149.26456770823847],[164.7683240410713,158.3232780822523],[164.7683240410713,158.3232780822523]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.60176086831838,147.5878230578733],[169.67079742775897,161.70386936171468],[169.67079742775897,161.70386936171468]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.58645024373618,146.4669291932127],[172.94807184136943,165.11883425295736],[172.94807184136943,165.11883425295736]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.42059682997896,145.85220900074034],[174.7453934449892,168.48231047786976],[174.7453934449892,168.48231047786976]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.9061668000921,145.6862229301544],[174.3461075040085,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[109.89444817484419,145.9078517026294],[167.00604580866076,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.28089606093508,146.4555207003181],[162.02428061713266,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.9992085169768,147.26962670455904],[158.59688923713986,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[133.01516676125698,148.29425928080326],[156.25574276290297,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.32062633606557,149.47832409788663],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[144.9279202573607,150.77617838683688],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.86483302362365,152.14788350454043],[153.29873006042072,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[154.1702249825266,153.55916933063904],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[157.89032775993167,154.98119240867067],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[161.07569026230578,156.39015609296857],[153.90410328496438,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[163.7787279457848,157.7668476672345],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[166.05181248302944,159.09613519733574],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.94583184319973,160.36645616899267],[156.46115967472622,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.5091497314232,161.56932089599852],[157.58326505555533,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.78689635032543,162.6988462564911],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[171.8205280053716,163.75132940997366],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.64760001130034,164.72486659520752],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.30170482167705,165.61901871326904],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.81253471130574,166.43452296328823],[164.04759992204185,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.20603531105112,167.17304813479808],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.50462260185213,167.836990104014],[166.61675437660261,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.72744150919286,168.42930348868117],[167.821185092542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.89064897156487,168.95336516806938],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.00770831106672,169.4128653744158],[169.99796493107303,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.08968496955248,169.81172223294573],[170.9492051502293,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.14553626674518,170.1540159102598],[171.7996075308126,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.18238987263604,170.44393888058158],[172.5457254570127,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.20580725120632,170.6857592030873],[173.18694334757177,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2200295079158,170.88379409754347],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.22820393443305,171.04239149313332],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23259015718264,171.16591759537795],[174.5136772126083,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2347452187699,171.25874886104174],[174.7787355115039,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23568720142907,171.32526708618758],[174.97042363235644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2360371788439,171.36985659529591],[175.0997505080749,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23613938850093,171.3969027679839],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2361595754449,171.41079135350373],[175.21906944484536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616148789233,171.41590820144467],[175.23402437178987,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"face_1_slot_3","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[100],"h":1},{"t":59,"s":[100],"h":1},{"t":60,"s":[100],"h":1},{"t":61,"s":[100],"h":1},{"t":62,"s":[100],"h":1},{"t":63,"s":[100],"h":1},{"t":64,"s":[100],"h":1},{"t":65,"s":[100],"h":1},{"t":66,"s":[100],"h":1},{"t":67,"s":[100],"h":1},{"t":68,"s":[100],"h":1},{"t":69,"s":[100],"h":1},{"t":70,"s":[100],"h":1},{"t":71,"s":[100],"h":1},{"t":72,"s":[100],"h":1},{"t":73,"s":[100],"h":1},{"t":74,"s":[100],"h":1},{"t":75,"s":[100],"h":1},{"t":76,"s":[100],"h":1},{"t":77,"s":[100],"h":1},{"t":78,"s":[100],"h":1},{"t":79,"s":[100],"h":1},{"t":80,"s":[100],"h":1},{"t":81,"s":[100],"h":1},{"t":82,"s":[100],"h":1},{"t":83,"s":[100],"h":1},{"t":84,"s":[100],"h":1},{"t":85,"s":[100],"h":1},{"t":86,"s":[100],"h":1},{"t":87,"s":[100],"h":1},{"t":88,"s":[100],"h":1},{"t":89,"s":[100],"h":1},{"t":90,"s":[100],"h":1},{"t":91,"s":[100],"h":1},{"t":92,"s":[100],"h":1},{"t":93,"s":[100],"h":1},{"t":94,"s":[100],"h":1},{"t":95,"s":[100],"h":1},{"t":96,"s":[100],"h":1},{"t":97,"s":[100],"h":1},{"t":98,"s":[100],"h":1},{"t":99,"s":[100],"h":1},{"t":100,"s":[100],"h":1},{"t":101,"s":[100],"h":1},{"t":102,"s":[100],"h":1},{"t":103,"s":[100],"h":1},{"t":104,"s":[100],"h":1},{"t":105,"s":[100],"h":1},{"t":106,"s":[100],"h":1},{"t":107,"s":[100],"h":1},{"t":108,"s":[100],"h":1},{"t":109,"s":[100],"h":1},{"t":110,"s":[100],"h":1},{"t":111,"s":[100],"h":1},{"t":112,"s":[100],"h":1},{"t":113,"s":[100],"h":1},{"t":114,"s":[100],"h":1},{"t":115,"s":[100],"h":1},{"t":116,"s":[100],"h":1},{"t":117,"s":[100],"h":1},{"t":118,"s":[100],"h":1},{"t":119,"s":[100],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.7659756282101,171.41663917972903],[24.763838512107654,171.4159082014447]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515457,171.41663917972903],[24.76384042455507,171.41079135350375]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[24.763860611499055,171.3969027679839]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192508,171.41663917972903],[24.7639628211561,171.36985659529594]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.02957636764353,171.41663917972903],[24.76431279857094,171.32526708618758]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496132,171.41663917972903],[24.765254781230098,171.25874886104174]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391742,171.41663917972903],[24.767409842817358,171.16591759537795]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087367,171.41663917972903],[24.771796065566974,171.04239149313335]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.27467983390227,171.41663917972903],[24.779970492084203,170.8837940975435]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.813056652428145,171.41663917972903],[24.794192748793662,170.68575920308734]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987264,171.41663917972903],[24.817610127363963,170.44393888058158]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187406,171.41663917972903],[24.854463733254804,170.1540159102598]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.050794849770682,171.41663917972903],[24.910315030447507,169.81172223294573]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926943,171.41663917972903],[24.992291688933264,169.41286537441582]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[25.109351028435142,168.95336516806938]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.17881490745795,171.41663917972903],[25.272558490807143,168.42930348868117]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339733,171.41663917972903],[25.495377398147852,167.83699010401403]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[25.793964688948876,167.17304813479808]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795812,171.41663917972903],[26.18746528869424,166.43452296328823]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312886,171.41663917972903],[26.698295178322923,165.61901871326907]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[27.352399988699673,164.72486659520754]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[28.179471994628386,163.75132940997366]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.20571106244855,171.41663917972903],[29.213103649674565,162.69884625649112]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444464,171.41663917972903],[30.49085026857678,161.56932089599854]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527375,171.41663917972903],[32.054168156800245,160.36645616899273]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009054,171.41663917972903],[33.948187516970535,159.09613519733577]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.4085587911411,171.41663917972903],[36.22127205421518,157.76684766723452]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.0958967150356,171.41663917972903],[38.92430973769417,156.39015609296857]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[42.10967224006833,154.9811924086707]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[45.82977501747337,153.55916933063907]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.701269939579305,171.41663917972903],[50.13516697637628,152.14788350454046]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[55.07207974263926,150.77617838683688]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042787,171.41663917972903],[60.67937366393438,149.47832409788663]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709705,171.41663917972903],[66.984833238743,148.29425928080326]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.40311076286019,171.41663917972903],[74.00079148302312,147.26962670455904]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.97571938286738,171.41663917972903],[81.71910393906488,146.4555207003181]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.99395419133931,171.41663917972903],[90.10555182515573,145.9078517026294]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.65389249599157,171.41663917972903],[99.09383319990782,145.6862229301544]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.254606555010795,168.4823104778698],[108.5794031700209,145.85220900074034],[108.5794031700209,145.85220900074034]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.051928158630545,165.11883425295738],[118.41354975626373,146.46692919321268],[118.41354975626373,146.46692919321268]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.329202572241016,161.70386936171468],[128.3982391316816,147.58782305787327],[128.3982391316816,147.58782305787327]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.23167595892863,158.32327808225233],[138.2824267897266,149.26456770823847],[138.2824267897266,149.26456770823847]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[41.867430278900926,155.08151817465492],[147.76069867183222,151.5341293518293],[147.76069867183222,151.5341293518293]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.290594742135035,152.10096254861588],[156.47526032617597,154.4150212688003],[156.47526032617597,154.4150212688003]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.48276971388455,149.51968659541475],[164.0224063161806,157.90095041343838],[164.0224063161806,157.90095041343838]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[72.33361100630755,147.4873228087879],[169.96464049825235,161.95417685080264],[169.96464049825235,161.95417685080264]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.62202576908322,146.15861054812373],[173.84953525253286,166.4990823725381],[173.84953525253286,166.4990823725381]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.37797423091669,146.15861054812373],[164.15776704046982,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[127.6663889936925,147.4873228087879],[157.9782885569902,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.51723028611542,149.51968659541475],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.7094052578649,152.10096254861585],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[158.13256972109895,155.08151817465486],[153.45457823622954,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.7683240410713,158.3232780822523],[154.92905150486584,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.67079742775897,161.70386936171468],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.94807184136943,165.11883425295736],[161.9568687706681,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.7453934449892,168.48231047786976],[167.93271906985146,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"face_2_slot_3","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[100],"h":1},{"t":50,"s":[100],"h":1},{"t":51,"s":[100],"h":1},{"t":52,"s":[100],"h":1},{"t":53,"s":[100],"h":1},{"t":54,"s":[100],"h":1},{"t":55,"s":[100],"h":1},{"t":56,"s":[100],"h":1},{"t":57,"s":[100],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838512107654,171.4159082014447],[100.00213723752422,145.68435644436695],[100.00213723752422,145.68435644436695]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76384042455507,171.41079135350375],[100.01709790004887,145.68435709846247],[100.01709790004887,145.68435709846247]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763860611499055,171.3969027679839],[100.05770540750385,145.68436400280396],[100.05770540750385,145.68436400280396]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.7639628211561,171.36985659529594],[100.1367831262165,145.68439896056552],[100.1367831262165,145.68439896056552]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76431279857094,171.32526708618758],[100.26715412914858,145.6845186598911],[100.26715412914858,145.6845186598911]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.765254781230098,171.25874886104174],[100.46164040852113,145.68484083693517],[100.46164040852113,145.68484083693517]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.767409842817358,171.16591759537795],[100.73306087154158,145.68557791140813],[100.73306087154158,145.68557791140813]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.771796065566974,171.04239149313335],[101.09422703281935,145.68707808794161],[101.09422703281935,145.68707808794161]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.779970492084203,170.8837940975435],[101.55793479589938,145.68987390647064],[101.55793479589938,145.68987390647064]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.794192748793662,170.68575920308734],[102.13695009169614,145.69473820474883],[102.13695009169614,145.69473820474883]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.817610127363963,170.44393888058158],[102.84398541468727,145.70274741992375],[102.84398541468727,145.70274741992375]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.854463733254804,170.1540159102598],[103.69166347102245,145.71535209549262],[103.69166347102245,145.71535209549262]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.910315030447507,169.81172223294573],[104.69246323090135,145.73445436416338],[104.69246323090135,145.73445436416338]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.992291688933264,169.41286537441582],[105.85864266890633,145.76249203264803],[105.85864266890633,145.76249203264803]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.109351028435142,168.95336516806938],[107.20213139409789,145.8025286847221],[107.20213139409789,145.8025286847221]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.272558490807143,168.42930348868117],[108.73438523824998,145.85834892439436],[108.73438523824998,145.85834892439436]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.495377398147852,167.83699010401403],[110.46619371861527,145.9345574790187],[110.46619371861527,145.9345574790187]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.793964688948876,167.17304813479808],[112.40743016966178,146.03668034701371],[112.40743016966178,146.03668034701371]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.18746528869424,166.43452296328823],[114.56673331575352,146.17126547853735],[114.56673331575352,146.17126547853735]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.698295178322923,165.61901871326907],[116.9511082303112,146.34597959060318],[116.9511082303112,146.34597959060318]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.352399988699673,164.72486659520754],[119.56543412751468,146.56969661159826],[119.56543412751468,146.56969661159826]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[28.179471994628386,163.75132940997366],[122.41186643342385,146.85257189760665],[122.41186643342385,146.85257189760665]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.213103649674565,162.69884625649112],[125.48912130867366,147.20609474441147],[125.48912130867366,147.20609474441147]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.49085026857678,161.56932089599854],[128.79163252777767,147.6431098261423],[128.79163252777767,147.6431098261423]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.054168156800245,160.36645616899273],[132.30857370939827,148.17779603433607],[132.30857370939827,148.17779603433607]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[33.948187516970535,159.09613519733577],[136.02274375594828,148.82558880736312],[136.02274375594828,148.82558880736312]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[36.22127205421518,157.76684766723452],[139.9093204855403,149.60302950658289],[139.9093204855403,149.60302950658289]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[38.92430973769417,156.39015609296857],[143.93449736804646,150.52752284250107],[143.93449736804646,150.52752284250107]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[42.10967224006833,154.9811924086707],[148.05403158786663,151.61698098210726],[148.05403158786663,151.61698098210726]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[45.82977501747337,153.55916933063907],[152.21174892054898,152.88933106722158],[152.21174892054898,152.88933106722158]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.13516697637628,152.14788350454046],[156.33807262878386,154.36186184207872],[156.33807262878386,154.36186184207872]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[55.07207974263926,150.77617838683688],[160.34867008765178,156.0503854539823],[160.34867008765178,156.0503854539823]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.67937366393438,149.47832409788663],[164.14334216845504,157.96819292461282],[164.14334216845504,157.96819292461282]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[66.984833238743,148.29425928080326],[167.60531609072166,160.12478711212304],[167.60531609072166,160.12478711212304]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.00079148302312,147.26962670455904],[170.60114132569498,162.52438615639866],[170.60114132569498,162.52438615639866]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.71910393906488,146.4555207003181],[172.98142804309384,165.16420448884634],[172.98142804309384,165.16420448884634]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[90.10555182515573,145.9078517026294],[174.58270506836902,168.0325385968404],[174.58270506836902,168.0325385968404]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.09383319990782,145.6862229301544],[175.2307042485341,171.10671188088455],[175.2307042485341,171.10671188088455]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.5794031700209,145.85220900074034],[167.93271906985157,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.41354975626373,146.46692919321268],[161.95686877066814,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.3982391316816,147.58782305787327],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[138.2824267897266,149.26456770823847],[154.92905150486587,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[147.76069867183222,151.5341293518293],[153.45457823622957,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.47526032617597,154.4150212688003],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.0224063161806,157.90095041343838],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.96464049825235,161.95417685080264],[157.97828855699024,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.84953525253286,166.4990823725381],[164.15776704046976,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.653892495991485,171.41663917972903],[24.76929575146589,171.10671188088457]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.993954191339256,171.41663917972903],[25.417294931630963,168.03253859684042]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.975719382867354,171.41663917972903],[27.01857195690613,165.16420448884637]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.403110762860145,171.41663917972903],[29.398858674304975,162.5243861563987]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709703,171.41663917972903],[32.394683909278314,160.12478711212307]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042786,171.41663917972903],[35.856657831544936,157.96819292461282]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[39.65132991234821,156.0503854539823]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.70126993957929,171.41663917972903],[43.661927371216095,154.36186184207872]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[47.78825107945101,152.88933106722158]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[51.94596841213336,151.61698098210726]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.095896715035614,171.41663917972903],[56.06550263195349,150.52752284250107]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.40855879114112,171.41663917972903],[60.09067951445965,149.6030295065829]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009055,171.41663917972903],[63.97725624405165,148.82558880736312]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527377,171.41663917972903],[67.69142629060164,148.1777960343361]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444467,171.41663917972903],[71.20836747222228,147.6431098261423]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.205711062448565,171.41663917972903],[74.5108786913263,147.20609474441147]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[77.58813356657613,146.85257189760665]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[80.43456587248531,146.56969661159826]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312914,171.41663917972903],[83.04889176968875,146.34597959060318]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795813,171.41663917972903],[85.43326668424646,146.17126547853735]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[87.59256983033822,146.03668034701371]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339737,171.41663917972903],[89.53380628138467,145.9345574790187]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.178814907457976,171.41663917972903],[91.26561476174996,145.85834892439436]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[92.79786860590211,145.8025286847221]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926972,171.41663917972903],[94.14135733109363,145.76249203264803]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.05079484977071,171.41663917972903],[95.30753676909862,145.73445436416338]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187392,171.41663917972903],[96.30833652897755,145.71535209549262]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987292,171.41663917972903],[97.1560145853127,145.70274741992375]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.81305665242823,171.41663917972903],[97.8630499083038,145.69473820474883]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.274679833902283,171.41663917972903],[98.44206520410059,145.68987390647064]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087395,171.41663917972903],[98.9057729671806,145.68707808794161]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391714,171.41663917972903],[99.26693912845845,145.68557791140813]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496118,171.41663917972903],[99.53835959147887,145.68484083693517]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.029576367643557,171.41663917972903],[99.7328458708514,145.6845186598911]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192511,171.41663917972903],[99.86321687378347,145.68439896056552]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[99.94229459249615,145.68436400280396]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515464,171.41663917972903],[99.98290209995109,145.6843570984625]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.765975628210114,171.41663917972903],[99.99786276247576,145.68435644436695]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751364,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"face_3_slot_3","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[100],"h":1},{"t":40,"s":[100],"h":1},{"t":41,"s":[100],"h":1},{"t":42,"s":[100],"h":1},{"t":43,"s":[100],"h":1},{"t":44,"s":[100],"h":1},{"t":45,"s":[100],"h":1},{"t":46,"s":[100],"h":1},{"t":47,"s":[100],"h":1},{"t":48,"s":[100],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.00213723752422,145.68435644436695],[175.2340243717899,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.01709790004887,145.68435709846247],[175.21906944484545,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.05770540750385,145.68436400280396],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.1367831262165,145.68439896056552],[175.09975050807492,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.26715412914858,145.6845186598911],[174.97042363235647,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.46164040852113,145.68484083693517],[174.77873551150387,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.73306087154158,145.68557791140813],[174.51367721260826,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.09422703281935,145.68707808794161],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.55793479589938,145.68987390647064],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.13695009169614,145.69473820474883],[173.18694334757186,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.84398541468727,145.70274741992375],[172.54572545701274,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[103.69166347102245,145.71535209549262],[171.79960753081258,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[104.69246323090135,145.73445436416338],[170.94920515022932,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[105.85864266890633,145.76249203264803],[169.99796493107306,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[107.20213139409789,145.8025286847221],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.73438523824998,145.85834892439436],[167.82118509254207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[110.46619371861527,145.9345574790187],[166.61675437660267,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[112.40743016966178,146.03668034701371],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.56673331575352,146.17126547853735],[164.04759992204188,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[116.9511082303112,146.34597959060318],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[119.56543412751468,146.56969661159826],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[122.41186643342385,146.85257189760665],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.48912130867366,147.20609474441147],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.79163252777767,147.6431098261423],[157.58326505555536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[132.30857370939827,148.17779603433607],[156.46115967472625,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[136.02274375594828,148.82558880736312],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.9093204855403,149.60302950658289],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[143.93449736804646,150.52752284250107],[153.9041032849644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[148.05403158786663,151.61698098210726],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[152.21174892054898,152.88933106722158],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.33807262878386,154.36186184207872],[153.2987300604207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[160.34867008765178,156.0503854539823],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.14334216845504,157.96819292461282],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.60531609072166,160.12478711212304],[156.25574276290294,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.60114132569498,162.52438615639866],[158.5968892371398,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.98142804309384,165.16420448884634],[162.0242806171326,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.58270506836902,168.0325385968404],[167.0060458086607,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2307042485341,171.10671188088455],[174.34610750400844,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530165,171.41663917972903],[26.15046474746714,166.49908237253814]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.02171144300978,171.41663917972903],[30.035359501747664,161.95417685080264]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[35.97759368381941,157.9009504134384]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112142,171.41663917972903],[43.524739673823944,154.41502126880033]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.54542176377046,171.41663917972903],[52.23930132816764,151.53412935182934]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513416,171.41663917972903],[61.71757321027329,149.26456770823847]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.28066065919667,171.41663917972903],[71.60176086831838,147.5878230578733]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331905,171.41663917972903],[81.58645024373618,146.4669291932127]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014854,171.41663917972903],[91.42059682997896,145.85220900074034]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76929575146589,171.10671188088457],[100.9061668000921,145.6862229301544],[100.9061668000921,145.6862229301544]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.417294931630963,168.03253859684042],[109.89444817484419,145.9078517026294],[109.89444817484419,145.9078517026294]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.01857195690613,165.16420448884637],[118.28089606093508,146.4555207003181],[118.28089606093508,146.4555207003181]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.398858674304975,162.5243861563987],[125.9992085169768,147.26962670455904],[125.9992085169768,147.26962670455904]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.394683909278314,160.12478711212307],[133.01516676125698,148.29425928080326],[133.01516676125698,148.29425928080326]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.856657831544936,157.96819292461282],[139.32062633606557,149.47832409788663],[139.32062633606557,149.47832409788663]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[39.65132991234821,156.0503854539823],[144.9279202573607,150.77617838683688],[144.9279202573607,150.77617838683688]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.661927371216095,154.36186184207872],[149.86483302362365,152.14788350454043],[149.86483302362365,152.14788350454043]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[47.78825107945101,152.88933106722158],[154.1702249825266,153.55916933063904],[154.1702249825266,153.55916933063904]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[51.94596841213336,151.61698098210726],[157.89032775993167,154.98119240867067],[157.89032775993167,154.98119240867067]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[56.06550263195349,150.52752284250107],[161.07569026230578,156.39015609296857],[161.07569026230578,156.39015609296857]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.09067951445965,149.6030295065829],[163.7787279457848,157.7668476672345],[163.7787279457848,157.7668476672345]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[63.97725624405165,148.82558880736312],[166.05181248302944,159.09613519733574],[166.05181248302944,159.09613519733574]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[67.69142629060164,148.1777960343361],[167.94583184319973,160.36645616899267],[167.94583184319973,160.36645616899267]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.20836747222228,147.6431098261423],[169.5091497314232,161.56932089599852],[169.5091497314232,161.56932089599852]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.5108786913263,147.20609474441147],[170.78689635032543,162.6988462564911],[170.78689635032543,162.6988462564911]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[77.58813356657613,146.85257189760665],[171.8205280053716,163.75132940997366],[171.8205280053716,163.75132940997366]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[80.43456587248531,146.56969661159826],[172.64760001130034,164.72486659520752],[172.64760001130034,164.72486659520752]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[83.04889176968875,146.34597959060318],[173.30170482167705,165.61901871326904],[173.30170482167705,165.61901871326904]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.43326668424646,146.17126547853735],[173.81253471130574,166.43452296328823],[173.81253471130574,166.43452296328823]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[87.59256983033822,146.03668034701371],[174.20603531105112,167.17304813479808],[174.20603531105112,167.17304813479808]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[89.53380628138467,145.9345574790187],[174.50462260185213,167.836990104014],[174.50462260185213,167.836990104014]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.26561476174996,145.85834892439436],[174.72744150919286,168.42930348868117],[174.72744150919286,168.42930348868117]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[92.79786860590211,145.8025286847221],[174.89064897156487,168.95336516806938],[174.89064897156487,168.95336516806938]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[94.14135733109363,145.76249203264803],[175.00770831106672,169.4128653744158],[175.00770831106672,169.4128653744158]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[95.30753676909862,145.73445436416338],[175.08968496955248,169.81172223294573],[175.08968496955248,169.81172223294573]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[96.30833652897755,145.71535209549262],[175.14553626674518,170.1540159102598],[175.14553626674518,170.1540159102598]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.1560145853127,145.70274741992375],[175.18238987263604,170.44393888058158],[175.18238987263604,170.44393888058158]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.8630499083038,145.69473820474883],[175.20580725120632,170.6857592030873],[175.20580725120632,170.6857592030873]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.44206520410059,145.68987390647064],[175.2200295079158,170.88379409754347],[175.2200295079158,170.88379409754347]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.9057729671806,145.68707808794161],[175.22820393443305,171.04239149313332],[175.22820393443305,171.04239149313332]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.26693912845845,145.68557791140813],[175.23259015718264,171.16591759537795],[175.23259015718264,171.16591759537795]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.53835959147887,145.68484083693517],[175.2347452187699,171.25874886104174],[175.2347452187699,171.25874886104174]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.7328458708514,145.6845186598911],[175.23568720142907,171.32526708618758],[175.23568720142907,171.32526708618758]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.86321687378347,145.68439896056552],[175.2360371788439,171.36985659529591],[175.2360371788439,171.36985659529591]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.94229459249615,145.68436400280396],[175.23613938850093,171.3969027679839],[175.23613938850093,171.3969027679839]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.98290209995109,145.6843570984625],[175.2361595754449,171.41079135350373],[175.2361595754449,171.41079135350373]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99786276247576,145.68435644436695],[175.23616148789233,171.41590820144467],[175.23616148789233,171.41590820144467]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"face_0_slot_2","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[100],"h":1},{"t":40,"s":[100],"h":1},{"t":41,"s":[100],"h":1},{"t":42,"s":[100],"h":1},{"t":43,"s":[100],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[100],"h":1},{"t":67,"s":[100],"h":1},{"t":68,"s":[100],"h":1},{"t":69,"s":[100],"h":1},{"t":70,"s":[100],"h":1},{"t":71,"s":[100],"h":1},{"t":72,"s":[100],"h":1},{"t":73,"s":[100],"h":1},{"t":74,"s":[100],"h":1},{"t":75,"s":[100],"h":1},{"t":76,"s":[100],"h":1},{"t":77,"s":[100],"h":1},{"t":78,"s":[100],"h":1},{"t":79,"s":[100],"h":1},{"t":80,"s":[100],"h":1},{"t":81,"s":[100],"h":1},{"t":82,"s":[100],"h":1},{"t":83,"s":[100],"h":1},{"t":84,"s":[100],"h":1},{"t":85,"s":[100],"h":1},{"t":86,"s":[100],"h":1},{"t":87,"s":[100],"h":1},{"t":88,"s":[100],"h":1},{"t":89,"s":[100],"h":1},{"t":90,"s":[100],"h":1},{"t":91,"s":[100],"h":1},{"t":92,"s":[100],"h":1},{"t":93,"s":[100],"h":1},{"t":94,"s":[100],"h":1},{"t":95,"s":[100],"h":1},{"t":96,"s":[100],"h":1},{"t":97,"s":[100],"h":1},{"t":98,"s":[100],"h":1},{"t":99,"s":[100],"h":1},{"t":100,"s":[100],"h":1},{"t":101,"s":[100],"h":1},{"t":102,"s":[100],"h":1},{"t":103,"s":[100],"h":1},{"t":104,"s":[100],"h":1},{"t":105,"s":[100],"h":1},{"t":106,"s":[100],"h":1},{"t":107,"s":[100],"h":1},{"t":108,"s":[100],"h":1},{"t":109,"s":[100],"h":1},{"t":110,"s":[100],"h":1},{"t":111,"s":[100],"h":1},{"t":112,"s":[100],"h":1},{"t":113,"s":[100],"h":1},{"t":114,"s":[100],"h":1},{"t":115,"s":[100],"h":1},{"t":116,"s":[100],"h":1},{"t":117,"s":[100],"h":1},{"t":118,"s":[100],"h":1},{"t":119,"s":[100],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014843,171.41663917972903],[25.254606555010795,168.4823104778698]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331856,171.41663917972903],[27.051928158630545,165.11883425295738]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.280660659196656,171.41663917972903],[30.329202572241016,161.70386936171468]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513414,171.41663917972903],[35.23167595892863,158.32327808225233]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.545421763770435,171.41663917972903],[41.867430278900926,155.08151817465492]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112144,171.41663917972903],[50.290594742135035,152.10096254861588]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[60.48276971388455,149.51968659541475]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.021711443009764,171.41663917972903],[72.33361100630755,147.4873228087879]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530236,171.41663917972903],[85.62202576908322,146.15861054812373]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.76383848175135,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.15046474746714,166.49908237253814],[114.37797423091669,146.15861054812373],[114.37797423091669,146.15861054812373]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.035359501747664,161.95417685080264],[127.6663889936925,147.4873228087879],[127.6663889936925,147.4873228087879]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.97759368381941,157.9009504134384],[139.51723028611542,149.51968659541475],[139.51723028611542,149.51968659541475]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.524739673823944,154.41502126880033],[149.7094052578649,152.10096254861585],[149.7094052578649,152.10096254861585]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[52.23930132816764,151.53412935182934],[158.13256972109895,155.08151817465486],[158.13256972109895,155.08151817465486]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[61.71757321027329,149.26456770823847],[164.7683240410713,158.3232780822523],[164.7683240410713,158.3232780822523]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.60176086831838,147.5878230578733],[169.67079742775897,161.70386936171468],[169.67079742775897,161.70386936171468]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.58645024373618,146.4669291932127],[172.94807184136943,165.11883425295736],[172.94807184136943,165.11883425295736]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.42059682997896,145.85220900074034],[174.7453934449892,168.48231047786976],[174.7453934449892,168.48231047786976]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.9061668000921,145.6862229301544],[174.3461075040085,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[109.89444817484419,145.9078517026294],[167.00604580866076,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.28089606093508,146.4555207003181],[162.02428061713266,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.9992085169768,147.26962670455904],[158.59688923713986,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[133.01516676125698,148.29425928080326],[156.25574276290297,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.32062633606557,149.47832409788663],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[144.9279202573607,150.77617838683688],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.86483302362365,152.14788350454043],[153.29873006042072,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[154.1702249825266,153.55916933063904],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[157.89032775993167,154.98119240867067],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[161.07569026230578,156.39015609296857],[153.90410328496438,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[163.7787279457848,157.7668476672345],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[166.05181248302944,159.09613519733574],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.94583184319973,160.36645616899267],[156.46115967472622,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.5091497314232,161.56932089599852],[157.58326505555533,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.78689635032543,162.6988462564911],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[171.8205280053716,163.75132940997366],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.64760001130034,164.72486659520752],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.30170482167705,165.61901871326904],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.81253471130574,166.43452296328823],[164.04759992204185,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.20603531105112,167.17304813479808],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.50462260185213,167.836990104014],[166.61675437660261,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.72744150919286,168.42930348868117],[167.821185092542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.89064897156487,168.95336516806938],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.00770831106672,169.4128653744158],[169.99796493107303,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.08968496955248,169.81172223294573],[170.9492051502293,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.14553626674518,170.1540159102598],[171.7996075308126,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.18238987263604,170.44393888058158],[172.5457254570127,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.20580725120632,170.6857592030873],[173.18694334757177,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2200295079158,170.88379409754347],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.22820393443305,171.04239149313332],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23259015718264,171.16591759537795],[174.5136772126083,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2347452187699,171.25874886104174],[174.7787355115039,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23568720142907,171.32526708618758],[174.97042363235644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2360371788439,171.36985659529591],[175.0997505080749,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23613938850093,171.3969027679839],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2361595754449,171.41079135350373],[175.21906944484536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616148789233,171.41590820144467],[175.23402437178987,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"face_1_slot_2","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[100],"h":1},{"t":2,"s":[100],"h":1},{"t":3,"s":[100],"h":1},{"t":4,"s":[100],"h":1},{"t":5,"s":[100],"h":1},{"t":6,"s":[100],"h":1},{"t":7,"s":[100],"h":1},{"t":8,"s":[100],"h":1},{"t":9,"s":[100],"h":1},{"t":10,"s":[100],"h":1},{"t":11,"s":[100],"h":1},{"t":12,"s":[100],"h":1},{"t":13,"s":[100],"h":1},{"t":14,"s":[100],"h":1},{"t":15,"s":[100],"h":1},{"t":16,"s":[100],"h":1},{"t":17,"s":[100],"h":1},{"t":18,"s":[100],"h":1},{"t":19,"s":[100],"h":1},{"t":20,"s":[100],"h":1},{"t":21,"s":[100],"h":1},{"t":22,"s":[100],"h":1},{"t":23,"s":[100],"h":1},{"t":24,"s":[100],"h":1},{"t":25,"s":[100],"h":1},{"t":26,"s":[100],"h":1},{"t":27,"s":[100],"h":1},{"t":28,"s":[100],"h":1},{"t":29,"s":[100],"h":1},{"t":30,"s":[100],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[100],"h":1},{"t":54,"s":[100],"h":1},{"t":55,"s":[100],"h":1},{"t":56,"s":[100],"h":1},{"t":57,"s":[100],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.7659756282101,171.41663917972903],[24.763838512107654,171.4159082014447]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515457,171.41663917972903],[24.76384042455507,171.41079135350375]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[24.763860611499055,171.3969027679839]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192508,171.41663917972903],[24.7639628211561,171.36985659529594]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.02957636764353,171.41663917972903],[24.76431279857094,171.32526708618758]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496132,171.41663917972903],[24.765254781230098,171.25874886104174]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391742,171.41663917972903],[24.767409842817358,171.16591759537795]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087367,171.41663917972903],[24.771796065566974,171.04239149313335]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.27467983390227,171.41663917972903],[24.779970492084203,170.8837940975435]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.813056652428145,171.41663917972903],[24.794192748793662,170.68575920308734]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987264,171.41663917972903],[24.817610127363963,170.44393888058158]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187406,171.41663917972903],[24.854463733254804,170.1540159102598]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.050794849770682,171.41663917972903],[24.910315030447507,169.81172223294573]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926943,171.41663917972903],[24.992291688933264,169.41286537441582]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[25.109351028435142,168.95336516806938]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.17881490745795,171.41663917972903],[25.272558490807143,168.42930348868117]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339733,171.41663917972903],[25.495377398147852,167.83699010401403]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[25.793964688948876,167.17304813479808]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795812,171.41663917972903],[26.18746528869424,166.43452296328823]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312886,171.41663917972903],[26.698295178322923,165.61901871326907]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[27.352399988699673,164.72486659520754]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[28.179471994628386,163.75132940997366]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.20571106244855,171.41663917972903],[29.213103649674565,162.69884625649112]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444464,171.41663917972903],[30.49085026857678,161.56932089599854]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527375,171.41663917972903],[32.054168156800245,160.36645616899273]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009054,171.41663917972903],[33.948187516970535,159.09613519733577]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.4085587911411,171.41663917972903],[36.22127205421518,157.76684766723452]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.0958967150356,171.41663917972903],[38.92430973769417,156.39015609296857]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[42.10967224006833,154.9811924086707]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[45.82977501747337,153.55916933063907]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.701269939579305,171.41663917972903],[50.13516697637628,152.14788350454046]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[55.07207974263926,150.77617838683688]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042787,171.41663917972903],[60.67937366393438,149.47832409788663]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709705,171.41663917972903],[66.984833238743,148.29425928080326]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.40311076286019,171.41663917972903],[74.00079148302312,147.26962670455904]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.97571938286738,171.41663917972903],[81.71910393906488,146.4555207003181]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.99395419133931,171.41663917972903],[90.10555182515573,145.9078517026294]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.65389249599157,171.41663917972903],[99.09383319990782,145.6862229301544]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.254606555010795,168.4823104778698],[108.5794031700209,145.85220900074034],[108.5794031700209,145.85220900074034]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.051928158630545,165.11883425295738],[118.41354975626373,146.46692919321268],[118.41354975626373,146.46692919321268]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.329202572241016,161.70386936171468],[128.3982391316816,147.58782305787327],[128.3982391316816,147.58782305787327]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.23167595892863,158.32327808225233],[138.2824267897266,149.26456770823847],[138.2824267897266,149.26456770823847]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[41.867430278900926,155.08151817465492],[147.76069867183222,151.5341293518293],[147.76069867183222,151.5341293518293]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.290594742135035,152.10096254861588],[156.47526032617597,154.4150212688003],[156.47526032617597,154.4150212688003]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.48276971388455,149.51968659541475],[164.0224063161806,157.90095041343838],[164.0224063161806,157.90095041343838]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[72.33361100630755,147.4873228087879],[169.96464049825235,161.95417685080264],[169.96464049825235,161.95417685080264]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.62202576908322,146.15861054812373],[173.84953525253286,166.4990823725381],[173.84953525253286,166.4990823725381]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.37797423091669,146.15861054812373],[164.15776704046982,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[127.6663889936925,147.4873228087879],[157.9782885569902,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.51723028611542,149.51968659541475],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.7094052578649,152.10096254861585],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[158.13256972109895,155.08151817465486],[153.45457823622954,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.7683240410713,158.3232780822523],[154.92905150486584,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.67079742775897,161.70386936171468],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.94807184136943,165.11883425295736],[161.9568687706681,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.7453934449892,168.48231047786976],[167.93271906985146,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"face_2_slot_2","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[100],"h":1},{"t":45,"s":[100],"h":1},{"t":46,"s":[100],"h":1},{"t":47,"s":[100],"h":1},{"t":48,"s":[100],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[100],"h":1},{"t":59,"s":[100],"h":1},{"t":60,"s":[100],"h":1},{"t":61,"s":[100],"h":1},{"t":62,"s":[100],"h":1},{"t":63,"s":[100],"h":1},{"t":64,"s":[100],"h":1},{"t":65,"s":[100],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838512107654,171.4159082014447],[100.00213723752422,145.68435644436695],[100.00213723752422,145.68435644436695]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76384042455507,171.41079135350375],[100.01709790004887,145.68435709846247],[100.01709790004887,145.68435709846247]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763860611499055,171.3969027679839],[100.05770540750385,145.68436400280396],[100.05770540750385,145.68436400280396]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.7639628211561,171.36985659529594],[100.1367831262165,145.68439896056552],[100.1367831262165,145.68439896056552]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76431279857094,171.32526708618758],[100.26715412914858,145.6845186598911],[100.26715412914858,145.6845186598911]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.765254781230098,171.25874886104174],[100.46164040852113,145.68484083693517],[100.46164040852113,145.68484083693517]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.767409842817358,171.16591759537795],[100.73306087154158,145.68557791140813],[100.73306087154158,145.68557791140813]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.771796065566974,171.04239149313335],[101.09422703281935,145.68707808794161],[101.09422703281935,145.68707808794161]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.779970492084203,170.8837940975435],[101.55793479589938,145.68987390647064],[101.55793479589938,145.68987390647064]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.794192748793662,170.68575920308734],[102.13695009169614,145.69473820474883],[102.13695009169614,145.69473820474883]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.817610127363963,170.44393888058158],[102.84398541468727,145.70274741992375],[102.84398541468727,145.70274741992375]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.854463733254804,170.1540159102598],[103.69166347102245,145.71535209549262],[103.69166347102245,145.71535209549262]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.910315030447507,169.81172223294573],[104.69246323090135,145.73445436416338],[104.69246323090135,145.73445436416338]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.992291688933264,169.41286537441582],[105.85864266890633,145.76249203264803],[105.85864266890633,145.76249203264803]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.109351028435142,168.95336516806938],[107.20213139409789,145.8025286847221],[107.20213139409789,145.8025286847221]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.272558490807143,168.42930348868117],[108.73438523824998,145.85834892439436],[108.73438523824998,145.85834892439436]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.495377398147852,167.83699010401403],[110.46619371861527,145.9345574790187],[110.46619371861527,145.9345574790187]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.793964688948876,167.17304813479808],[112.40743016966178,146.03668034701371],[112.40743016966178,146.03668034701371]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.18746528869424,166.43452296328823],[114.56673331575352,146.17126547853735],[114.56673331575352,146.17126547853735]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.698295178322923,165.61901871326907],[116.9511082303112,146.34597959060318],[116.9511082303112,146.34597959060318]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.352399988699673,164.72486659520754],[119.56543412751468,146.56969661159826],[119.56543412751468,146.56969661159826]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[28.179471994628386,163.75132940997366],[122.41186643342385,146.85257189760665],[122.41186643342385,146.85257189760665]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.213103649674565,162.69884625649112],[125.48912130867366,147.20609474441147],[125.48912130867366,147.20609474441147]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.49085026857678,161.56932089599854],[128.79163252777767,147.6431098261423],[128.79163252777767,147.6431098261423]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.054168156800245,160.36645616899273],[132.30857370939827,148.17779603433607],[132.30857370939827,148.17779603433607]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[33.948187516970535,159.09613519733577],[136.02274375594828,148.82558880736312],[136.02274375594828,148.82558880736312]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[36.22127205421518,157.76684766723452],[139.9093204855403,149.60302950658289],[139.9093204855403,149.60302950658289]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[38.92430973769417,156.39015609296857],[143.93449736804646,150.52752284250107],[143.93449736804646,150.52752284250107]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[42.10967224006833,154.9811924086707],[148.05403158786663,151.61698098210726],[148.05403158786663,151.61698098210726]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[45.82977501747337,153.55916933063907],[152.21174892054898,152.88933106722158],[152.21174892054898,152.88933106722158]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.13516697637628,152.14788350454046],[156.33807262878386,154.36186184207872],[156.33807262878386,154.36186184207872]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[55.07207974263926,150.77617838683688],[160.34867008765178,156.0503854539823],[160.34867008765178,156.0503854539823]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.67937366393438,149.47832409788663],[164.14334216845504,157.96819292461282],[164.14334216845504,157.96819292461282]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[66.984833238743,148.29425928080326],[167.60531609072166,160.12478711212304],[167.60531609072166,160.12478711212304]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.00079148302312,147.26962670455904],[170.60114132569498,162.52438615639866],[170.60114132569498,162.52438615639866]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.71910393906488,146.4555207003181],[172.98142804309384,165.16420448884634],[172.98142804309384,165.16420448884634]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[90.10555182515573,145.9078517026294],[174.58270506836902,168.0325385968404],[174.58270506836902,168.0325385968404]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.09383319990782,145.6862229301544],[175.2307042485341,171.10671188088455],[175.2307042485341,171.10671188088455]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.5794031700209,145.85220900074034],[167.93271906985157,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.41354975626373,146.46692919321268],[161.95686877066814,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.3982391316816,147.58782305787327],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[138.2824267897266,149.26456770823847],[154.92905150486587,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[147.76069867183222,151.5341293518293],[153.45457823622957,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.47526032617597,154.4150212688003],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.0224063161806,157.90095041343838],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.96464049825235,161.95417685080264],[157.97828855699024,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.84953525253286,166.4990823725381],[164.15776704046976,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.653892495991485,171.41663917972903],[24.76929575146589,171.10671188088457]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.993954191339256,171.41663917972903],[25.417294931630963,168.03253859684042]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.975719382867354,171.41663917972903],[27.01857195690613,165.16420448884637]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.403110762860145,171.41663917972903],[29.398858674304975,162.5243861563987]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709703,171.41663917972903],[32.394683909278314,160.12478711212307]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042786,171.41663917972903],[35.856657831544936,157.96819292461282]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[39.65132991234821,156.0503854539823]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.70126993957929,171.41663917972903],[43.661927371216095,154.36186184207872]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[47.78825107945101,152.88933106722158]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[51.94596841213336,151.61698098210726]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.095896715035614,171.41663917972903],[56.06550263195349,150.52752284250107]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.40855879114112,171.41663917972903],[60.09067951445965,149.6030295065829]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009055,171.41663917972903],[63.97725624405165,148.82558880736312]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527377,171.41663917972903],[67.69142629060164,148.1777960343361]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444467,171.41663917972903],[71.20836747222228,147.6431098261423]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.205711062448565,171.41663917972903],[74.5108786913263,147.20609474441147]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[77.58813356657613,146.85257189760665]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[80.43456587248531,146.56969661159826]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312914,171.41663917972903],[83.04889176968875,146.34597959060318]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795813,171.41663917972903],[85.43326668424646,146.17126547853735]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[87.59256983033822,146.03668034701371]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339737,171.41663917972903],[89.53380628138467,145.9345574790187]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.178814907457976,171.41663917972903],[91.26561476174996,145.85834892439436]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[92.79786860590211,145.8025286847221]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926972,171.41663917972903],[94.14135733109363,145.76249203264803]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.05079484977071,171.41663917972903],[95.30753676909862,145.73445436416338]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187392,171.41663917972903],[96.30833652897755,145.71535209549262]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987292,171.41663917972903],[97.1560145853127,145.70274741992375]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.81305665242823,171.41663917972903],[97.8630499083038,145.69473820474883]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.274679833902283,171.41663917972903],[98.44206520410059,145.68987390647064]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087395,171.41663917972903],[98.9057729671806,145.68707808794161]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391714,171.41663917972903],[99.26693912845845,145.68557791140813]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496118,171.41663917972903],[99.53835959147887,145.68484083693517]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.029576367643557,171.41663917972903],[99.7328458708514,145.6845186598911]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192511,171.41663917972903],[99.86321687378347,145.68439896056552]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[99.94229459249615,145.68436400280396]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515464,171.41663917972903],[99.98290209995109,145.6843570984625]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.765975628210114,171.41663917972903],[99.99786276247576,145.68435644436695]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751364,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"face_3_slot_2","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[100],"h":1},{"t":32,"s":[100],"h":1},{"t":33,"s":[100],"h":1},{"t":34,"s":[100],"h":1},{"t":35,"s":[100],"h":1},{"t":36,"s":[100],"h":1},{"t":37,"s":[100],"h":1},{"t":38,"s":[100],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[100],"h":1},{"t":50,"s":[100],"h":1},{"t":51,"s":[100],"h":1},{"t":52,"s":[100],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.00213723752422,145.68435644436695],[175.2340243717899,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.01709790004887,145.68435709846247],[175.21906944484545,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.05770540750385,145.68436400280396],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.1367831262165,145.68439896056552],[175.09975050807492,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.26715412914858,145.6845186598911],[174.97042363235647,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.46164040852113,145.68484083693517],[174.77873551150387,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.73306087154158,145.68557791140813],[174.51367721260826,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.09422703281935,145.68707808794161],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.55793479589938,145.68987390647064],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.13695009169614,145.69473820474883],[173.18694334757186,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.84398541468727,145.70274741992375],[172.54572545701274,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[103.69166347102245,145.71535209549262],[171.79960753081258,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[104.69246323090135,145.73445436416338],[170.94920515022932,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[105.85864266890633,145.76249203264803],[169.99796493107306,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[107.20213139409789,145.8025286847221],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.73438523824998,145.85834892439436],[167.82118509254207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[110.46619371861527,145.9345574790187],[166.61675437660267,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[112.40743016966178,146.03668034701371],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.56673331575352,146.17126547853735],[164.04759992204188,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[116.9511082303112,146.34597959060318],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[119.56543412751468,146.56969661159826],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[122.41186643342385,146.85257189760665],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.48912130867366,147.20609474441147],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.79163252777767,147.6431098261423],[157.58326505555536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[132.30857370939827,148.17779603433607],[156.46115967472625,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[136.02274375594828,148.82558880736312],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.9093204855403,149.60302950658289],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[143.93449736804646,150.52752284250107],[153.9041032849644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[148.05403158786663,151.61698098210726],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[152.21174892054898,152.88933106722158],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.33807262878386,154.36186184207872],[153.2987300604207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[160.34867008765178,156.0503854539823],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.14334216845504,157.96819292461282],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.60531609072166,160.12478711212304],[156.25574276290294,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.60114132569498,162.52438615639866],[158.5968892371398,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.98142804309384,165.16420448884634],[162.0242806171326,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.58270506836902,168.0325385968404],[167.0060458086607,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2307042485341,171.10671188088455],[174.34610750400844,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530165,171.41663917972903],[26.15046474746714,166.49908237253814]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.02171144300978,171.41663917972903],[30.035359501747664,161.95417685080264]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[35.97759368381941,157.9009504134384]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112142,171.41663917972903],[43.524739673823944,154.41502126880033]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.54542176377046,171.41663917972903],[52.23930132816764,151.53412935182934]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513416,171.41663917972903],[61.71757321027329,149.26456770823847]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.28066065919667,171.41663917972903],[71.60176086831838,147.5878230578733]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331905,171.41663917972903],[81.58645024373618,146.4669291932127]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014854,171.41663917972903],[91.42059682997896,145.85220900074034]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76929575146589,171.10671188088457],[100.9061668000921,145.6862229301544],[100.9061668000921,145.6862229301544]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.417294931630963,168.03253859684042],[109.89444817484419,145.9078517026294],[109.89444817484419,145.9078517026294]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.01857195690613,165.16420448884637],[118.28089606093508,146.4555207003181],[118.28089606093508,146.4555207003181]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.398858674304975,162.5243861563987],[125.9992085169768,147.26962670455904],[125.9992085169768,147.26962670455904]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.394683909278314,160.12478711212307],[133.01516676125698,148.29425928080326],[133.01516676125698,148.29425928080326]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.856657831544936,157.96819292461282],[139.32062633606557,149.47832409788663],[139.32062633606557,149.47832409788663]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[39.65132991234821,156.0503854539823],[144.9279202573607,150.77617838683688],[144.9279202573607,150.77617838683688]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.661927371216095,154.36186184207872],[149.86483302362365,152.14788350454043],[149.86483302362365,152.14788350454043]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[47.78825107945101,152.88933106722158],[154.1702249825266,153.55916933063904],[154.1702249825266,153.55916933063904]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[51.94596841213336,151.61698098210726],[157.89032775993167,154.98119240867067],[157.89032775993167,154.98119240867067]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[56.06550263195349,150.52752284250107],[161.07569026230578,156.39015609296857],[161.07569026230578,156.39015609296857]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.09067951445965,149.6030295065829],[163.7787279457848,157.7668476672345],[163.7787279457848,157.7668476672345]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[63.97725624405165,148.82558880736312],[166.05181248302944,159.09613519733574],[166.05181248302944,159.09613519733574]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[67.69142629060164,148.1777960343361],[167.94583184319973,160.36645616899267],[167.94583184319973,160.36645616899267]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.20836747222228,147.6431098261423],[169.5091497314232,161.56932089599852],[169.5091497314232,161.56932089599852]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.5108786913263,147.20609474441147],[170.78689635032543,162.6988462564911],[170.78689635032543,162.6988462564911]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[77.58813356657613,146.85257189760665],[171.8205280053716,163.75132940997366],[171.8205280053716,163.75132940997366]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[80.43456587248531,146.56969661159826],[172.64760001130034,164.72486659520752],[172.64760001130034,164.72486659520752]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[83.04889176968875,146.34597959060318],[173.30170482167705,165.61901871326904],[173.30170482167705,165.61901871326904]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.43326668424646,146.17126547853735],[173.81253471130574,166.43452296328823],[173.81253471130574,166.43452296328823]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[87.59256983033822,146.03668034701371],[174.20603531105112,167.17304813479808],[174.20603531105112,167.17304813479808]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[89.53380628138467,145.9345574790187],[174.50462260185213,167.836990104014],[174.50462260185213,167.836990104014]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.26561476174996,145.85834892439436],[174.72744150919286,168.42930348868117],[174.72744150919286,168.42930348868117]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[92.79786860590211,145.8025286847221],[174.89064897156487,168.95336516806938],[174.89064897156487,168.95336516806938]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[94.14135733109363,145.76249203264803],[175.00770831106672,169.4128653744158],[175.00770831106672,169.4128653744158]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[95.30753676909862,145.73445436416338],[175.08968496955248,169.81172223294573],[175.08968496955248,169.81172223294573]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[96.30833652897755,145.71535209549262],[175.14553626674518,170.1540159102598],[175.14553626674518,170.1540159102598]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.1560145853127,145.70274741992375],[175.18238987263604,170.44393888058158],[175.18238987263604,170.44393888058158]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.8630499083038,145.69473820474883],[175.20580725120632,170.6857592030873],[175.20580725120632,170.6857592030873]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.44206520410059,145.68987390647064],[175.2200295079158,170.88379409754347],[175.2200295079158,170.88379409754347]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.9057729671806,145.68707808794161],[175.22820393443305,171.04239149313332],[175.22820393443305,171.04239149313332]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.26693912845845,145.68557791140813],[175.23259015718264,171.16591759537795],[175.23259015718264,171.16591759537795]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.53835959147887,145.68484083693517],[175.2347452187699,171.25874886104174],[175.2347452187699,171.25874886104174]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.7328458708514,145.6845186598911],[175.23568720142907,171.32526708618758],[175.23568720142907,171.32526708618758]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.86321687378347,145.68439896056552],[175.2360371788439,171.36985659529591],[175.2360371788439,171.36985659529591]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.94229459249615,145.68436400280396],[175.23613938850093,171.3969027679839],[175.23613938850093,171.3969027679839]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.98290209995109,145.6843570984625],[175.2361595754449,171.41079135350373],[175.2361595754449,171.41079135350373]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99786276247576,145.68435644436695],[175.23616148789233,171.41590820144467],[175.23616148789233,171.41590820144467]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"face_0_slot_1","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[100],"h":1},{"t":45,"s":[100],"h":1},{"t":46,"s":[100],"h":1},{"t":47,"s":[100],"h":1},{"t":48,"s":[100],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[100],"h":1},{"t":59,"s":[100],"h":1},{"t":60,"s":[100],"h":1},{"t":61,"s":[100],"h":1},{"t":62,"s":[100],"h":1},{"t":63,"s":[100],"h":1},{"t":64,"s":[100],"h":1},{"t":65,"s":[100],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014843,171.41663917972903],[25.254606555010795,168.4823104778698]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331856,171.41663917972903],[27.051928158630545,165.11883425295738]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.280660659196656,171.41663917972903],[30.329202572241016,161.70386936171468]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513414,171.41663917972903],[35.23167595892863,158.32327808225233]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.545421763770435,171.41663917972903],[41.867430278900926,155.08151817465492]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112144,171.41663917972903],[50.290594742135035,152.10096254861588]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[60.48276971388455,149.51968659541475]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.021711443009764,171.41663917972903],[72.33361100630755,147.4873228087879]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530236,171.41663917972903],[85.62202576908322,146.15861054812373]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.76383848175135,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.15046474746714,166.49908237253814],[114.37797423091669,146.15861054812373],[114.37797423091669,146.15861054812373]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.035359501747664,161.95417685080264],[127.6663889936925,147.4873228087879],[127.6663889936925,147.4873228087879]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.97759368381941,157.9009504134384],[139.51723028611542,149.51968659541475],[139.51723028611542,149.51968659541475]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.524739673823944,154.41502126880033],[149.7094052578649,152.10096254861585],[149.7094052578649,152.10096254861585]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[52.23930132816764,151.53412935182934],[158.13256972109895,155.08151817465486],[158.13256972109895,155.08151817465486]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[61.71757321027329,149.26456770823847],[164.7683240410713,158.3232780822523],[164.7683240410713,158.3232780822523]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.60176086831838,147.5878230578733],[169.67079742775897,161.70386936171468],[169.67079742775897,161.70386936171468]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.58645024373618,146.4669291932127],[172.94807184136943,165.11883425295736],[172.94807184136943,165.11883425295736]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.42059682997896,145.85220900074034],[174.7453934449892,168.48231047786976],[174.7453934449892,168.48231047786976]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.9061668000921,145.6862229301544],[174.3461075040085,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[109.89444817484419,145.9078517026294],[167.00604580866076,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.28089606093508,146.4555207003181],[162.02428061713266,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.9992085169768,147.26962670455904],[158.59688923713986,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[133.01516676125698,148.29425928080326],[156.25574276290297,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.32062633606557,149.47832409788663],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[144.9279202573607,150.77617838683688],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.86483302362365,152.14788350454043],[153.29873006042072,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[154.1702249825266,153.55916933063904],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[157.89032775993167,154.98119240867067],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[161.07569026230578,156.39015609296857],[153.90410328496438,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[163.7787279457848,157.7668476672345],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[166.05181248302944,159.09613519733574],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.94583184319973,160.36645616899267],[156.46115967472622,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.5091497314232,161.56932089599852],[157.58326505555533,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.78689635032543,162.6988462564911],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[171.8205280053716,163.75132940997366],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.64760001130034,164.72486659520752],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.30170482167705,165.61901871326904],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.81253471130574,166.43452296328823],[164.04759992204185,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.20603531105112,167.17304813479808],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.50462260185213,167.836990104014],[166.61675437660261,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.72744150919286,168.42930348868117],[167.821185092542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.89064897156487,168.95336516806938],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.00770831106672,169.4128653744158],[169.99796493107303,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.08968496955248,169.81172223294573],[170.9492051502293,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.14553626674518,170.1540159102598],[171.7996075308126,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.18238987263604,170.44393888058158],[172.5457254570127,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.20580725120632,170.6857592030873],[173.18694334757177,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2200295079158,170.88379409754347],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.22820393443305,171.04239149313332],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23259015718264,171.16591759537795],[174.5136772126083,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2347452187699,171.25874886104174],[174.7787355115039,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23568720142907,171.32526708618758],[174.97042363235644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2360371788439,171.36985659529591],[175.0997505080749,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23613938850093,171.3969027679839],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2361595754449,171.41079135350373],[175.21906944484536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616148789233,171.41590820144467],[175.23402437178987,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"face_1_slot_1","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[100],"h":1},{"t":32,"s":[100],"h":1},{"t":33,"s":[100],"h":1},{"t":34,"s":[100],"h":1},{"t":35,"s":[100],"h":1},{"t":36,"s":[100],"h":1},{"t":37,"s":[100],"h":1},{"t":38,"s":[100],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[100],"h":1},{"t":50,"s":[100],"h":1},{"t":51,"s":[100],"h":1},{"t":52,"s":[100],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.7659756282101,171.41663917972903],[24.763838512107654,171.4159082014447]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515457,171.41663917972903],[24.76384042455507,171.41079135350375]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[24.763860611499055,171.3969027679839]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192508,171.41663917972903],[24.7639628211561,171.36985659529594]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.02957636764353,171.41663917972903],[24.76431279857094,171.32526708618758]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496132,171.41663917972903],[24.765254781230098,171.25874886104174]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391742,171.41663917972903],[24.767409842817358,171.16591759537795]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087367,171.41663917972903],[24.771796065566974,171.04239149313335]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.27467983390227,171.41663917972903],[24.779970492084203,170.8837940975435]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.813056652428145,171.41663917972903],[24.794192748793662,170.68575920308734]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987264,171.41663917972903],[24.817610127363963,170.44393888058158]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187406,171.41663917972903],[24.854463733254804,170.1540159102598]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.050794849770682,171.41663917972903],[24.910315030447507,169.81172223294573]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926943,171.41663917972903],[24.992291688933264,169.41286537441582]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[25.109351028435142,168.95336516806938]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.17881490745795,171.41663917972903],[25.272558490807143,168.42930348868117]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339733,171.41663917972903],[25.495377398147852,167.83699010401403]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[25.793964688948876,167.17304813479808]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795812,171.41663917972903],[26.18746528869424,166.43452296328823]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312886,171.41663917972903],[26.698295178322923,165.61901871326907]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[27.352399988699673,164.72486659520754]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[28.179471994628386,163.75132940997366]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.20571106244855,171.41663917972903],[29.213103649674565,162.69884625649112]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444464,171.41663917972903],[30.49085026857678,161.56932089599854]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527375,171.41663917972903],[32.054168156800245,160.36645616899273]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009054,171.41663917972903],[33.948187516970535,159.09613519733577]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.4085587911411,171.41663917972903],[36.22127205421518,157.76684766723452]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.0958967150356,171.41663917972903],[38.92430973769417,156.39015609296857]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[42.10967224006833,154.9811924086707]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[45.82977501747337,153.55916933063907]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.701269939579305,171.41663917972903],[50.13516697637628,152.14788350454046]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[55.07207974263926,150.77617838683688]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042787,171.41663917972903],[60.67937366393438,149.47832409788663]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709705,171.41663917972903],[66.984833238743,148.29425928080326]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.40311076286019,171.41663917972903],[74.00079148302312,147.26962670455904]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.97571938286738,171.41663917972903],[81.71910393906488,146.4555207003181]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.99395419133931,171.41663917972903],[90.10555182515573,145.9078517026294]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.65389249599157,171.41663917972903],[99.09383319990782,145.6862229301544]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.254606555010795,168.4823104778698],[108.5794031700209,145.85220900074034],[108.5794031700209,145.85220900074034]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.051928158630545,165.11883425295738],[118.41354975626373,146.46692919321268],[118.41354975626373,146.46692919321268]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.329202572241016,161.70386936171468],[128.3982391316816,147.58782305787327],[128.3982391316816,147.58782305787327]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.23167595892863,158.32327808225233],[138.2824267897266,149.26456770823847],[138.2824267897266,149.26456770823847]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[41.867430278900926,155.08151817465492],[147.76069867183222,151.5341293518293],[147.76069867183222,151.5341293518293]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.290594742135035,152.10096254861588],[156.47526032617597,154.4150212688003],[156.47526032617597,154.4150212688003]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.48276971388455,149.51968659541475],[164.0224063161806,157.90095041343838],[164.0224063161806,157.90095041343838]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[72.33361100630755,147.4873228087879],[169.96464049825235,161.95417685080264],[169.96464049825235,161.95417685080264]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.62202576908322,146.15861054812373],[173.84953525253286,166.4990823725381],[173.84953525253286,166.4990823725381]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.37797423091669,146.15861054812373],[164.15776704046982,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[127.6663889936925,147.4873228087879],[157.9782885569902,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.51723028611542,149.51968659541475],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.7094052578649,152.10096254861585],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[158.13256972109895,155.08151817465486],[153.45457823622954,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.7683240410713,158.3232780822523],[154.92905150486584,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.67079742775897,161.70386936171468],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.94807184136943,165.11883425295736],[161.9568687706681,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.7453934449892,168.48231047786976],[167.93271906985146,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"face_2_slot_1","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[100],"h":1},{"t":40,"s":[100],"h":1},{"t":41,"s":[100],"h":1},{"t":42,"s":[100],"h":1},{"t":43,"s":[100],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[100],"h":1},{"t":67,"s":[100],"h":1},{"t":68,"s":[100],"h":1},{"t":69,"s":[100],"h":1},{"t":70,"s":[100],"h":1},{"t":71,"s":[100],"h":1},{"t":72,"s":[100],"h":1},{"t":73,"s":[100],"h":1},{"t":74,"s":[100],"h":1},{"t":75,"s":[100],"h":1},{"t":76,"s":[100],"h":1},{"t":77,"s":[100],"h":1},{"t":78,"s":[100],"h":1},{"t":79,"s":[100],"h":1},{"t":80,"s":[100],"h":1},{"t":81,"s":[100],"h":1},{"t":82,"s":[100],"h":1},{"t":83,"s":[100],"h":1},{"t":84,"s":[100],"h":1},{"t":85,"s":[100],"h":1},{"t":86,"s":[100],"h":1},{"t":87,"s":[100],"h":1},{"t":88,"s":[100],"h":1},{"t":89,"s":[100],"h":1},{"t":90,"s":[100],"h":1},{"t":91,"s":[100],"h":1},{"t":92,"s":[100],"h":1},{"t":93,"s":[100],"h":1},{"t":94,"s":[100],"h":1},{"t":95,"s":[100],"h":1},{"t":96,"s":[100],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838512107654,171.4159082014447],[100.00213723752422,145.68435644436695],[100.00213723752422,145.68435644436695]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76384042455507,171.41079135350375],[100.01709790004887,145.68435709846247],[100.01709790004887,145.68435709846247]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763860611499055,171.3969027679839],[100.05770540750385,145.68436400280396],[100.05770540750385,145.68436400280396]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.7639628211561,171.36985659529594],[100.1367831262165,145.68439896056552],[100.1367831262165,145.68439896056552]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76431279857094,171.32526708618758],[100.26715412914858,145.6845186598911],[100.26715412914858,145.6845186598911]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.765254781230098,171.25874886104174],[100.46164040852113,145.68484083693517],[100.46164040852113,145.68484083693517]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.767409842817358,171.16591759537795],[100.73306087154158,145.68557791140813],[100.73306087154158,145.68557791140813]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.771796065566974,171.04239149313335],[101.09422703281935,145.68707808794161],[101.09422703281935,145.68707808794161]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.779970492084203,170.8837940975435],[101.55793479589938,145.68987390647064],[101.55793479589938,145.68987390647064]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.794192748793662,170.68575920308734],[102.13695009169614,145.69473820474883],[102.13695009169614,145.69473820474883]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.817610127363963,170.44393888058158],[102.84398541468727,145.70274741992375],[102.84398541468727,145.70274741992375]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.854463733254804,170.1540159102598],[103.69166347102245,145.71535209549262],[103.69166347102245,145.71535209549262]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.910315030447507,169.81172223294573],[104.69246323090135,145.73445436416338],[104.69246323090135,145.73445436416338]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.992291688933264,169.41286537441582],[105.85864266890633,145.76249203264803],[105.85864266890633,145.76249203264803]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.109351028435142,168.95336516806938],[107.20213139409789,145.8025286847221],[107.20213139409789,145.8025286847221]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.272558490807143,168.42930348868117],[108.73438523824998,145.85834892439436],[108.73438523824998,145.85834892439436]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.495377398147852,167.83699010401403],[110.46619371861527,145.9345574790187],[110.46619371861527,145.9345574790187]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.793964688948876,167.17304813479808],[112.40743016966178,146.03668034701371],[112.40743016966178,146.03668034701371]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.18746528869424,166.43452296328823],[114.56673331575352,146.17126547853735],[114.56673331575352,146.17126547853735]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.698295178322923,165.61901871326907],[116.9511082303112,146.34597959060318],[116.9511082303112,146.34597959060318]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.352399988699673,164.72486659520754],[119.56543412751468,146.56969661159826],[119.56543412751468,146.56969661159826]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[28.179471994628386,163.75132940997366],[122.41186643342385,146.85257189760665],[122.41186643342385,146.85257189760665]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.213103649674565,162.69884625649112],[125.48912130867366,147.20609474441147],[125.48912130867366,147.20609474441147]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.49085026857678,161.56932089599854],[128.79163252777767,147.6431098261423],[128.79163252777767,147.6431098261423]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.054168156800245,160.36645616899273],[132.30857370939827,148.17779603433607],[132.30857370939827,148.17779603433607]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[33.948187516970535,159.09613519733577],[136.02274375594828,148.82558880736312],[136.02274375594828,148.82558880736312]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[36.22127205421518,157.76684766723452],[139.9093204855403,149.60302950658289],[139.9093204855403,149.60302950658289]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[38.92430973769417,156.39015609296857],[143.93449736804646,150.52752284250107],[143.93449736804646,150.52752284250107]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[42.10967224006833,154.9811924086707],[148.05403158786663,151.61698098210726],[148.05403158786663,151.61698098210726]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[45.82977501747337,153.55916933063907],[152.21174892054898,152.88933106722158],[152.21174892054898,152.88933106722158]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.13516697637628,152.14788350454046],[156.33807262878386,154.36186184207872],[156.33807262878386,154.36186184207872]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[55.07207974263926,150.77617838683688],[160.34867008765178,156.0503854539823],[160.34867008765178,156.0503854539823]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.67937366393438,149.47832409788663],[164.14334216845504,157.96819292461282],[164.14334216845504,157.96819292461282]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[66.984833238743,148.29425928080326],[167.60531609072166,160.12478711212304],[167.60531609072166,160.12478711212304]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.00079148302312,147.26962670455904],[170.60114132569498,162.52438615639866],[170.60114132569498,162.52438615639866]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.71910393906488,146.4555207003181],[172.98142804309384,165.16420448884634],[172.98142804309384,165.16420448884634]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[90.10555182515573,145.9078517026294],[174.58270506836902,168.0325385968404],[174.58270506836902,168.0325385968404]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.09383319990782,145.6862229301544],[175.2307042485341,171.10671188088455],[175.2307042485341,171.10671188088455]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.5794031700209,145.85220900074034],[167.93271906985157,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.41354975626373,146.46692919321268],[161.95686877066814,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.3982391316816,147.58782305787327],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[138.2824267897266,149.26456770823847],[154.92905150486587,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[147.76069867183222,151.5341293518293],[153.45457823622957,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.47526032617597,154.4150212688003],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.0224063161806,157.90095041343838],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.96464049825235,161.95417685080264],[157.97828855699024,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.84953525253286,166.4990823725381],[164.15776704046976,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.653892495991485,171.41663917972903],[24.76929575146589,171.10671188088457]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.993954191339256,171.41663917972903],[25.417294931630963,168.03253859684042]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.975719382867354,171.41663917972903],[27.01857195690613,165.16420448884637]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.403110762860145,171.41663917972903],[29.398858674304975,162.5243861563987]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709703,171.41663917972903],[32.394683909278314,160.12478711212307]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042786,171.41663917972903],[35.856657831544936,157.96819292461282]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[39.65132991234821,156.0503854539823]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.70126993957929,171.41663917972903],[43.661927371216095,154.36186184207872]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[47.78825107945101,152.88933106722158]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[51.94596841213336,151.61698098210726]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.095896715035614,171.41663917972903],[56.06550263195349,150.52752284250107]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.40855879114112,171.41663917972903],[60.09067951445965,149.6030295065829]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009055,171.41663917972903],[63.97725624405165,148.82558880736312]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527377,171.41663917972903],[67.69142629060164,148.1777960343361]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444467,171.41663917972903],[71.20836747222228,147.6431098261423]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.205711062448565,171.41663917972903],[74.5108786913263,147.20609474441147]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[77.58813356657613,146.85257189760665]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[80.43456587248531,146.56969661159826]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312914,171.41663917972903],[83.04889176968875,146.34597959060318]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795813,171.41663917972903],[85.43326668424646,146.17126547853735]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[87.59256983033822,146.03668034701371]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339737,171.41663917972903],[89.53380628138467,145.9345574790187]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.178814907457976,171.41663917972903],[91.26561476174996,145.85834892439436]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[92.79786860590211,145.8025286847221]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926972,171.41663917972903],[94.14135733109363,145.76249203264803]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.05079484977071,171.41663917972903],[95.30753676909862,145.73445436416338]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187392,171.41663917972903],[96.30833652897755,145.71535209549262]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987292,171.41663917972903],[97.1560145853127,145.70274741992375]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.81305665242823,171.41663917972903],[97.8630499083038,145.69473820474883]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.274679833902283,171.41663917972903],[98.44206520410059,145.68987390647064]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087395,171.41663917972903],[98.9057729671806,145.68707808794161]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391714,171.41663917972903],[99.26693912845845,145.68557791140813]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496118,171.41663917972903],[99.53835959147887,145.68484083693517]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.029576367643557,171.41663917972903],[99.7328458708514,145.6845186598911]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192511,171.41663917972903],[99.86321687378347,145.68439896056552]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[99.94229459249615,145.68436400280396]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515464,171.41663917972903],[99.98290209995109,145.6843570984625]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.765975628210114,171.41663917972903],[99.99786276247576,145.68435644436695]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751364,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"face_3_slot_1","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":1,"s":[100],"h":1},{"t":2,"s":[100],"h":1},{"t":3,"s":[100],"h":1},{"t":4,"s":[100],"h":1},{"t":5,"s":[100],"h":1},{"t":6,"s":[100],"h":1},{"t":7,"s":[100],"h":1},{"t":8,"s":[100],"h":1},{"t":9,"s":[100],"h":1},{"t":10,"s":[100],"h":1},{"t":11,"s":[100],"h":1},{"t":12,"s":[100],"h":1},{"t":13,"s":[100],"h":1},{"t":14,"s":[100],"h":1},{"t":15,"s":[100],"h":1},{"t":16,"s":[100],"h":1},{"t":17,"s":[100],"h":1},{"t":18,"s":[100],"h":1},{"t":19,"s":[100],"h":1},{"t":20,"s":[100],"h":1},{"t":21,"s":[100],"h":1},{"t":22,"s":[100],"h":1},{"t":23,"s":[100],"h":1},{"t":24,"s":[100],"h":1},{"t":25,"s":[100],"h":1},{"t":26,"s":[100],"h":1},{"t":27,"s":[100],"h":1},{"t":28,"s":[100],"h":1},{"t":29,"s":[100],"h":1},{"t":30,"s":[100],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[100],"h":1},{"t":54,"s":[100],"h":1},{"t":55,"s":[100],"h":1},{"t":56,"s":[100],"h":1},{"t":57,"s":[100],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[100],"h":1},{"t":98,"s":[100],"h":1},{"t":99,"s":[100],"h":1},{"t":100,"s":[100],"h":1},{"t":101,"s":[100],"h":1},{"t":102,"s":[100],"h":1},{"t":103,"s":[100],"h":1},{"t":104,"s":[100],"h":1},{"t":105,"s":[100],"h":1},{"t":106,"s":[100],"h":1},{"t":107,"s":[100],"h":1},{"t":108,"s":[100],"h":1},{"t":109,"s":[100],"h":1},{"t":110,"s":[100],"h":1},{"t":111,"s":[100],"h":1},{"t":112,"s":[100],"h":1},{"t":113,"s":[100],"h":1},{"t":114,"s":[100],"h":1},{"t":115,"s":[100],"h":1},{"t":116,"s":[100],"h":1},{"t":117,"s":[100],"h":1},{"t":118,"s":[100],"h":1},{"t":119,"s":[100],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.00213723752422,145.68435644436695],[175.2340243717899,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.01709790004887,145.68435709846247],[175.21906944484545,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.05770540750385,145.68436400280396],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.1367831262165,145.68439896056552],[175.09975050807492,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.26715412914858,145.6845186598911],[174.97042363235647,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.46164040852113,145.68484083693517],[174.77873551150387,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.73306087154158,145.68557791140813],[174.51367721260826,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.09422703281935,145.68707808794161],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.55793479589938,145.68987390647064],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.13695009169614,145.69473820474883],[173.18694334757186,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.84398541468727,145.70274741992375],[172.54572545701274,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[103.69166347102245,145.71535209549262],[171.79960753081258,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[104.69246323090135,145.73445436416338],[170.94920515022932,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[105.85864266890633,145.76249203264803],[169.99796493107306,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[107.20213139409789,145.8025286847221],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.73438523824998,145.85834892439436],[167.82118509254207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[110.46619371861527,145.9345574790187],[166.61675437660267,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[112.40743016966178,146.03668034701371],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.56673331575352,146.17126547853735],[164.04759992204188,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[116.9511082303112,146.34597959060318],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[119.56543412751468,146.56969661159826],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[122.41186643342385,146.85257189760665],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.48912130867366,147.20609474441147],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.79163252777767,147.6431098261423],[157.58326505555536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[132.30857370939827,148.17779603433607],[156.46115967472625,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[136.02274375594828,148.82558880736312],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.9093204855403,149.60302950658289],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[143.93449736804646,150.52752284250107],[153.9041032849644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[148.05403158786663,151.61698098210726],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[152.21174892054898,152.88933106722158],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.33807262878386,154.36186184207872],[153.2987300604207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[160.34867008765178,156.0503854539823],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.14334216845504,157.96819292461282],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.60531609072166,160.12478711212304],[156.25574276290294,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.60114132569498,162.52438615639866],[158.5968892371398,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.98142804309384,165.16420448884634],[162.0242806171326,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.58270506836902,168.0325385968404],[167.0060458086607,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2307042485341,171.10671188088455],[174.34610750400844,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530165,171.41663917972903],[26.15046474746714,166.49908237253814]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.02171144300978,171.41663917972903],[30.035359501747664,161.95417685080264]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[35.97759368381941,157.9009504134384]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112142,171.41663917972903],[43.524739673823944,154.41502126880033]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.54542176377046,171.41663917972903],[52.23930132816764,151.53412935182934]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513416,171.41663917972903],[61.71757321027329,149.26456770823847]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.28066065919667,171.41663917972903],[71.60176086831838,147.5878230578733]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331905,171.41663917972903],[81.58645024373618,146.4669291932127]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014854,171.41663917972903],[91.42059682997896,145.85220900074034]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76929575146589,171.10671188088457],[100.9061668000921,145.6862229301544],[100.9061668000921,145.6862229301544]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.417294931630963,168.03253859684042],[109.89444817484419,145.9078517026294],[109.89444817484419,145.9078517026294]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.01857195690613,165.16420448884637],[118.28089606093508,146.4555207003181],[118.28089606093508,146.4555207003181]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.398858674304975,162.5243861563987],[125.9992085169768,147.26962670455904],[125.9992085169768,147.26962670455904]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.394683909278314,160.12478711212307],[133.01516676125698,148.29425928080326],[133.01516676125698,148.29425928080326]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.856657831544936,157.96819292461282],[139.32062633606557,149.47832409788663],[139.32062633606557,149.47832409788663]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[39.65132991234821,156.0503854539823],[144.9279202573607,150.77617838683688],[144.9279202573607,150.77617838683688]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.661927371216095,154.36186184207872],[149.86483302362365,152.14788350454043],[149.86483302362365,152.14788350454043]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[47.78825107945101,152.88933106722158],[154.1702249825266,153.55916933063904],[154.1702249825266,153.55916933063904]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[51.94596841213336,151.61698098210726],[157.89032775993167,154.98119240867067],[157.89032775993167,154.98119240867067]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[56.06550263195349,150.52752284250107],[161.07569026230578,156.39015609296857],[161.07569026230578,156.39015609296857]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.09067951445965,149.6030295065829],[163.7787279457848,157.7668476672345],[163.7787279457848,157.7668476672345]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[63.97725624405165,148.82558880736312],[166.05181248302944,159.09613519733574],[166.05181248302944,159.09613519733574]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[67.69142629060164,148.1777960343361],[167.94583184319973,160.36645616899267],[167.94583184319973,160.36645616899267]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.20836747222228,147.6431098261423],[169.5091497314232,161.56932089599852],[169.5091497314232,161.56932089599852]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.5108786913263,147.20609474441147],[170.78689635032543,162.6988462564911],[170.78689635032543,162.6988462564911]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[77.58813356657613,146.85257189760665],[171.8205280053716,163.75132940997366],[171.8205280053716,163.75132940997366]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[80.43456587248531,146.56969661159826],[172.64760001130034,164.72486659520752],[172.64760001130034,164.72486659520752]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[83.04889176968875,146.34597959060318],[173.30170482167705,165.61901871326904],[173.30170482167705,165.61901871326904]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.43326668424646,146.17126547853735],[173.81253471130574,166.43452296328823],[173.81253471130574,166.43452296328823]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[87.59256983033822,146.03668034701371],[174.20603531105112,167.17304813479808],[174.20603531105112,167.17304813479808]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[89.53380628138467,145.9345574790187],[174.50462260185213,167.836990104014],[174.50462260185213,167.836990104014]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.26561476174996,145.85834892439436],[174.72744150919286,168.42930348868117],[174.72744150919286,168.42930348868117]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[92.79786860590211,145.8025286847221],[174.89064897156487,168.95336516806938],[174.89064897156487,168.95336516806938]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[94.14135733109363,145.76249203264803],[175.00770831106672,169.4128653744158],[175.00770831106672,169.4128653744158]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[95.30753676909862,145.73445436416338],[175.08968496955248,169.81172223294573],[175.08968496955248,169.81172223294573]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[96.30833652897755,145.71535209549262],[175.14553626674518,170.1540159102598],[175.14553626674518,170.1540159102598]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.1560145853127,145.70274741992375],[175.18238987263604,170.44393888058158],[175.18238987263604,170.44393888058158]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.8630499083038,145.69473820474883],[175.20580725120632,170.6857592030873],[175.20580725120632,170.6857592030873]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.44206520410059,145.68987390647064],[175.2200295079158,170.88379409754347],[175.2200295079158,170.88379409754347]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.9057729671806,145.68707808794161],[175.22820393443305,171.04239149313332],[175.22820393443305,171.04239149313332]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.26693912845845,145.68557791140813],[175.23259015718264,171.16591759537795],[175.23259015718264,171.16591759537795]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.53835959147887,145.68484083693517],[175.2347452187699,171.25874886104174],[175.2347452187699,171.25874886104174]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.7328458708514,145.6845186598911],[175.23568720142907,171.32526708618758],[175.23568720142907,171.32526708618758]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.86321687378347,145.68439896056552],[175.2360371788439,171.36985659529591],[175.2360371788439,171.36985659529591]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.94229459249615,145.68436400280396],[175.23613938850093,171.3969027679839],[175.23613938850093,171.3969027679839]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.98290209995109,145.6843570984625],[175.2361595754449,171.41079135350373],[175.2361595754449,171.41079135350373]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99786276247576,145.68435644436695],[175.23616148789233,171.41590820144467],[175.23616148789233,171.41590820144467]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"face_0_slot_0","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[100],"h":1},{"t":50,"s":[100],"h":1},{"t":51,"s":[100],"h":1},{"t":52,"s":[100],"h":1},{"t":53,"s":[100],"h":1},{"t":54,"s":[100],"h":1},{"t":55,"s":[100],"h":1},{"t":56,"s":[100],"h":1},{"t":57,"s":[100],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014843,171.41663917972903],[25.254606555010795,168.4823104778698]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331856,171.41663917972903],[27.051928158630545,165.11883425295738]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.280660659196656,171.41663917972903],[30.329202572241016,161.70386936171468]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513414,171.41663917972903],[35.23167595892863,158.32327808225233]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.545421763770435,171.41663917972903],[41.867430278900926,155.08151817465492]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112144,171.41663917972903],[50.290594742135035,152.10096254861588]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[60.48276971388455,149.51968659541475]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.021711443009764,171.41663917972903],[72.33361100630755,147.4873228087879]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530236,171.41663917972903],[85.62202576908322,146.15861054812373]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.76383848175135,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.15046474746714,166.49908237253814],[114.37797423091669,146.15861054812373],[114.37797423091669,146.15861054812373]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.035359501747664,161.95417685080264],[127.6663889936925,147.4873228087879],[127.6663889936925,147.4873228087879]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.97759368381941,157.9009504134384],[139.51723028611542,149.51968659541475],[139.51723028611542,149.51968659541475]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.524739673823944,154.41502126880033],[149.7094052578649,152.10096254861585],[149.7094052578649,152.10096254861585]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[52.23930132816764,151.53412935182934],[158.13256972109895,155.08151817465486],[158.13256972109895,155.08151817465486]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[61.71757321027329,149.26456770823847],[164.7683240410713,158.3232780822523],[164.7683240410713,158.3232780822523]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.60176086831838,147.5878230578733],[169.67079742775897,161.70386936171468],[169.67079742775897,161.70386936171468]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.58645024373618,146.4669291932127],[172.94807184136943,165.11883425295736],[172.94807184136943,165.11883425295736]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.42059682997896,145.85220900074034],[174.7453934449892,168.48231047786976],[174.7453934449892,168.48231047786976]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.9061668000921,145.6862229301544],[174.3461075040085,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[109.89444817484419,145.9078517026294],[167.00604580866076,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.28089606093508,146.4555207003181],[162.02428061713266,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.9992085169768,147.26962670455904],[158.59688923713986,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[133.01516676125698,148.29425928080326],[156.25574276290297,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.32062633606557,149.47832409788663],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[144.9279202573607,150.77617838683688],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.86483302362365,152.14788350454043],[153.29873006042072,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[154.1702249825266,153.55916933063904],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[157.89032775993167,154.98119240867067],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[161.07569026230578,156.39015609296857],[153.90410328496438,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[163.7787279457848,157.7668476672345],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[166.05181248302944,159.09613519733574],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.94583184319973,160.36645616899267],[156.46115967472622,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.5091497314232,161.56932089599852],[157.58326505555533,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.78689635032543,162.6988462564911],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[171.8205280053716,163.75132940997366],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.64760001130034,164.72486659520752],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.30170482167705,165.61901871326904],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.81253471130574,166.43452296328823],[164.04759992204185,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.20603531105112,167.17304813479808],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.50462260185213,167.836990104014],[166.61675437660261,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.72744150919286,168.42930348868117],[167.821185092542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.89064897156487,168.95336516806938],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.00770831106672,169.4128653744158],[169.99796493107303,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.08968496955248,169.81172223294573],[170.9492051502293,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.14553626674518,170.1540159102598],[171.7996075308126,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.18238987263604,170.44393888058158],[172.5457254570127,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.20580725120632,170.6857592030873],[173.18694334757177,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2200295079158,170.88379409754347],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.22820393443305,171.04239149313332],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23259015718264,171.16591759537795],[174.5136772126083,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2347452187699,171.25874886104174],[174.7787355115039,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23568720142907,171.32526708618758],[174.97042363235644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2360371788439,171.36985659529591],[175.0997505080749,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23613938850093,171.3969027679839],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2361595754449,171.41079135350373],[175.21906944484536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616148789233,171.41590820144467],[175.23402437178987,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"face_1_slot_0","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[100],"h":1},{"t":40,"s":[100],"h":1},{"t":41,"s":[100],"h":1},{"t":42,"s":[100],"h":1},{"t":43,"s":[100],"h":1},{"t":44,"s":[100],"h":1},{"t":45,"s":[100],"h":1},{"t":46,"s":[100],"h":1},{"t":47,"s":[100],"h":1},{"t":48,"s":[100],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.7659756282101,171.41663917972903],[24.763838512107654,171.4159082014447]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515457,171.41663917972903],[24.76384042455507,171.41079135350375]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[24.763860611499055,171.3969027679839]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192508,171.41663917972903],[24.7639628211561,171.36985659529594]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.02957636764353,171.41663917972903],[24.76431279857094,171.32526708618758]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496132,171.41663917972903],[24.765254781230098,171.25874886104174]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391742,171.41663917972903],[24.767409842817358,171.16591759537795]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087367,171.41663917972903],[24.771796065566974,171.04239149313335]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.27467983390227,171.41663917972903],[24.779970492084203,170.8837940975435]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.813056652428145,171.41663917972903],[24.794192748793662,170.68575920308734]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987264,171.41663917972903],[24.817610127363963,170.44393888058158]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187406,171.41663917972903],[24.854463733254804,170.1540159102598]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.050794849770682,171.41663917972903],[24.910315030447507,169.81172223294573]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926943,171.41663917972903],[24.992291688933264,169.41286537441582]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[25.109351028435142,168.95336516806938]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.17881490745795,171.41663917972903],[25.272558490807143,168.42930348868117]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339733,171.41663917972903],[25.495377398147852,167.83699010401403]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[25.793964688948876,167.17304813479808]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795812,171.41663917972903],[26.18746528869424,166.43452296328823]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312886,171.41663917972903],[26.698295178322923,165.61901871326907]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[27.352399988699673,164.72486659520754]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[28.179471994628386,163.75132940997366]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.20571106244855,171.41663917972903],[29.213103649674565,162.69884625649112]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444464,171.41663917972903],[30.49085026857678,161.56932089599854]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527375,171.41663917972903],[32.054168156800245,160.36645616899273]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009054,171.41663917972903],[33.948187516970535,159.09613519733577]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.4085587911411,171.41663917972903],[36.22127205421518,157.76684766723452]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.0958967150356,171.41663917972903],[38.92430973769417,156.39015609296857]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[42.10967224006833,154.9811924086707]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[45.82977501747337,153.55916933063907]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.701269939579305,171.41663917972903],[50.13516697637628,152.14788350454046]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[55.07207974263926,150.77617838683688]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042787,171.41663917972903],[60.67937366393438,149.47832409788663]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709705,171.41663917972903],[66.984833238743,148.29425928080326]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.40311076286019,171.41663917972903],[74.00079148302312,147.26962670455904]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.97571938286738,171.41663917972903],[81.71910393906488,146.4555207003181]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.99395419133931,171.41663917972903],[90.10555182515573,145.9078517026294]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.65389249599157,171.41663917972903],[99.09383319990782,145.6862229301544]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.254606555010795,168.4823104778698],[108.5794031700209,145.85220900074034],[108.5794031700209,145.85220900074034]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.051928158630545,165.11883425295738],[118.41354975626373,146.46692919321268],[118.41354975626373,146.46692919321268]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.329202572241016,161.70386936171468],[128.3982391316816,147.58782305787327],[128.3982391316816,147.58782305787327]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.23167595892863,158.32327808225233],[138.2824267897266,149.26456770823847],[138.2824267897266,149.26456770823847]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[41.867430278900926,155.08151817465492],[147.76069867183222,151.5341293518293],[147.76069867183222,151.5341293518293]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.290594742135035,152.10096254861588],[156.47526032617597,154.4150212688003],[156.47526032617597,154.4150212688003]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.48276971388455,149.51968659541475],[164.0224063161806,157.90095041343838],[164.0224063161806,157.90095041343838]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[72.33361100630755,147.4873228087879],[169.96464049825235,161.95417685080264],[169.96464049825235,161.95417685080264]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.62202576908322,146.15861054812373],[173.84953525253286,166.4990823725381],[173.84953525253286,166.4990823725381]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.37797423091669,146.15861054812373],[164.15776704046982,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[127.6663889936925,147.4873228087879],[157.9782885569902,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.51723028611542,149.51968659541475],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.7094052578649,152.10096254861585],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[158.13256972109895,155.08151817465486],[153.45457823622954,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.7683240410713,158.3232780822523],[154.92905150486584,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.67079742775897,161.70386936171468],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.94807184136943,165.11883425295736],[161.9568687706681,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.7453934449892,168.48231047786976],[167.93271906985146,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"face_2_slot_0","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":1,"s":[100],"h":1},{"t":2,"s":[100],"h":1},{"t":3,"s":[100],"h":1},{"t":4,"s":[100],"h":1},{"t":5,"s":[100],"h":1},{"t":6,"s":[100],"h":1},{"t":7,"s":[100],"h":1},{"t":8,"s":[100],"h":1},{"t":9,"s":[100],"h":1},{"t":10,"s":[100],"h":1},{"t":11,"s":[100],"h":1},{"t":12,"s":[100],"h":1},{"t":13,"s":[100],"h":1},{"t":14,"s":[100],"h":1},{"t":15,"s":[100],"h":1},{"t":16,"s":[100],"h":1},{"t":17,"s":[100],"h":1},{"t":18,"s":[100],"h":1},{"t":19,"s":[100],"h":1},{"t":20,"s":[100],"h":1},{"t":21,"s":[100],"h":1},{"t":22,"s":[100],"h":1},{"t":23,"s":[100],"h":1},{"t":24,"s":[100],"h":1},{"t":25,"s":[100],"h":1},{"t":26,"s":[100],"h":1},{"t":27,"s":[100],"h":1},{"t":28,"s":[100],"h":1},{"t":29,"s":[100],"h":1},{"t":30,"s":[100],"h":1},{"t":31,"s":[100],"h":1},{"t":32,"s":[100],"h":1},{"t":33,"s":[100],"h":1},{"t":34,"s":[100],"h":1},{"t":35,"s":[100],"h":1},{"t":36,"s":[100],"h":1},{"t":37,"s":[100],"h":1},{"t":38,"s":[100],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[100],"h":1},{"t":98,"s":[100],"h":1},{"t":99,"s":[100],"h":1},{"t":100,"s":[100],"h":1},{"t":101,"s":[100],"h":1},{"t":102,"s":[100],"h":1},{"t":103,"s":[100],"h":1},{"t":104,"s":[100],"h":1},{"t":105,"s":[100],"h":1},{"t":106,"s":[100],"h":1},{"t":107,"s":[100],"h":1},{"t":108,"s":[100],"h":1},{"t":109,"s":[100],"h":1},{"t":110,"s":[100],"h":1},{"t":111,"s":[100],"h":1},{"t":112,"s":[100],"h":1},{"t":113,"s":[100],"h":1},{"t":114,"s":[100],"h":1},{"t":115,"s":[100],"h":1},{"t":116,"s":[100],"h":1},{"t":117,"s":[100],"h":1},{"t":118,"s":[100],"h":1},{"t":119,"s":[100],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838512107654,171.4159082014447],[100.00213723752422,145.68435644436695],[100.00213723752422,145.68435644436695]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76384042455507,171.41079135350375],[100.01709790004887,145.68435709846247],[100.01709790004887,145.68435709846247]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763860611499055,171.3969027679839],[100.05770540750385,145.68436400280396],[100.05770540750385,145.68436400280396]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.7639628211561,171.36985659529594],[100.1367831262165,145.68439896056552],[100.1367831262165,145.68439896056552]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76431279857094,171.32526708618758],[100.26715412914858,145.6845186598911],[100.26715412914858,145.6845186598911]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.765254781230098,171.25874886104174],[100.46164040852113,145.68484083693517],[100.46164040852113,145.68484083693517]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.767409842817358,171.16591759537795],[100.73306087154158,145.68557791140813],[100.73306087154158,145.68557791140813]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.771796065566974,171.04239149313335],[101.09422703281935,145.68707808794161],[101.09422703281935,145.68707808794161]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.779970492084203,170.8837940975435],[101.55793479589938,145.68987390647064],[101.55793479589938,145.68987390647064]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.794192748793662,170.68575920308734],[102.13695009169614,145.69473820474883],[102.13695009169614,145.69473820474883]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.817610127363963,170.44393888058158],[102.84398541468727,145.70274741992375],[102.84398541468727,145.70274741992375]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.854463733254804,170.1540159102598],[103.69166347102245,145.71535209549262],[103.69166347102245,145.71535209549262]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.910315030447507,169.81172223294573],[104.69246323090135,145.73445436416338],[104.69246323090135,145.73445436416338]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.992291688933264,169.41286537441582],[105.85864266890633,145.76249203264803],[105.85864266890633,145.76249203264803]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.109351028435142,168.95336516806938],[107.20213139409789,145.8025286847221],[107.20213139409789,145.8025286847221]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.272558490807143,168.42930348868117],[108.73438523824998,145.85834892439436],[108.73438523824998,145.85834892439436]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.495377398147852,167.83699010401403],[110.46619371861527,145.9345574790187],[110.46619371861527,145.9345574790187]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.793964688948876,167.17304813479808],[112.40743016966178,146.03668034701371],[112.40743016966178,146.03668034701371]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.18746528869424,166.43452296328823],[114.56673331575352,146.17126547853735],[114.56673331575352,146.17126547853735]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.698295178322923,165.61901871326907],[116.9511082303112,146.34597959060318],[116.9511082303112,146.34597959060318]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.352399988699673,164.72486659520754],[119.56543412751468,146.56969661159826],[119.56543412751468,146.56969661159826]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[28.179471994628386,163.75132940997366],[122.41186643342385,146.85257189760665],[122.41186643342385,146.85257189760665]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.213103649674565,162.69884625649112],[125.48912130867366,147.20609474441147],[125.48912130867366,147.20609474441147]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.49085026857678,161.56932089599854],[128.79163252777767,147.6431098261423],[128.79163252777767,147.6431098261423]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.054168156800245,160.36645616899273],[132.30857370939827,148.17779603433607],[132.30857370939827,148.17779603433607]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[33.948187516970535,159.09613519733577],[136.02274375594828,148.82558880736312],[136.02274375594828,148.82558880736312]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[36.22127205421518,157.76684766723452],[139.9093204855403,149.60302950658289],[139.9093204855403,149.60302950658289]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[38.92430973769417,156.39015609296857],[143.93449736804646,150.52752284250107],[143.93449736804646,150.52752284250107]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[42.10967224006833,154.9811924086707],[148.05403158786663,151.61698098210726],[148.05403158786663,151.61698098210726]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[45.82977501747337,153.55916933063907],[152.21174892054898,152.88933106722158],[152.21174892054898,152.88933106722158]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.13516697637628,152.14788350454046],[156.33807262878386,154.36186184207872],[156.33807262878386,154.36186184207872]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[55.07207974263926,150.77617838683688],[160.34867008765178,156.0503854539823],[160.34867008765178,156.0503854539823]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.67937366393438,149.47832409788663],[164.14334216845504,157.96819292461282],[164.14334216845504,157.96819292461282]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[66.984833238743,148.29425928080326],[167.60531609072166,160.12478711212304],[167.60531609072166,160.12478711212304]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.00079148302312,147.26962670455904],[170.60114132569498,162.52438615639866],[170.60114132569498,162.52438615639866]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.71910393906488,146.4555207003181],[172.98142804309384,165.16420448884634],[172.98142804309384,165.16420448884634]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[90.10555182515573,145.9078517026294],[174.58270506836902,168.0325385968404],[174.58270506836902,168.0325385968404]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.09383319990782,145.6862229301544],[175.2307042485341,171.10671188088455],[175.2307042485341,171.10671188088455]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.5794031700209,145.85220900074034],[167.93271906985157,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.41354975626373,146.46692919321268],[161.95686877066814,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.3982391316816,147.58782305787327],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[138.2824267897266,149.26456770823847],[154.92905150486587,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[147.76069867183222,151.5341293518293],[153.45457823622957,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.47526032617597,154.4150212688003],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.0224063161806,157.90095041343838],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.96464049825235,161.95417685080264],[157.97828855699024,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.84953525253286,166.4990823725381],[164.15776704046976,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.653892495991485,171.41663917972903],[24.76929575146589,171.10671188088457]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.993954191339256,171.41663917972903],[25.417294931630963,168.03253859684042]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.975719382867354,171.41663917972903],[27.01857195690613,165.16420448884637]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.403110762860145,171.41663917972903],[29.398858674304975,162.5243861563987]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709703,171.41663917972903],[32.394683909278314,160.12478711212307]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042786,171.41663917972903],[35.856657831544936,157.96819292461282]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[39.65132991234821,156.0503854539823]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.70126993957929,171.41663917972903],[43.661927371216095,154.36186184207872]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[47.78825107945101,152.88933106722158]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[51.94596841213336,151.61698098210726]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.095896715035614,171.41663917972903],[56.06550263195349,150.52752284250107]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.40855879114112,171.41663917972903],[60.09067951445965,149.6030295065829]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009055,171.41663917972903],[63.97725624405165,148.82558880736312]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527377,171.41663917972903],[67.69142629060164,148.1777960343361]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444467,171.41663917972903],[71.20836747222228,147.6431098261423]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.205711062448565,171.41663917972903],[74.5108786913263,147.20609474441147]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[77.58813356657613,146.85257189760665]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[80.43456587248531,146.56969661159826]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312914,171.41663917972903],[83.04889176968875,146.34597959060318]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795813,171.41663917972903],[85.43326668424646,146.17126547853735]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[87.59256983033822,146.03668034701371]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339737,171.41663917972903],[89.53380628138467,145.9345574790187]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.178814907457976,171.41663917972903],[91.26561476174996,145.85834892439436]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[92.79786860590211,145.8025286847221]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926972,171.41663917972903],[94.14135733109363,145.76249203264803]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.05079484977071,171.41663917972903],[95.30753676909862,145.73445436416338]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187392,171.41663917972903],[96.30833652897755,145.71535209549262]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987292,171.41663917972903],[97.1560145853127,145.70274741992375]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.81305665242823,171.41663917972903],[97.8630499083038,145.69473820474883]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.274679833902283,171.41663917972903],[98.44206520410059,145.68987390647064]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087395,171.41663917972903],[98.9057729671806,145.68707808794161]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391714,171.41663917972903],[99.26693912845845,145.68557791140813]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496118,171.41663917972903],[99.53835959147887,145.68484083693517]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.029576367643557,171.41663917972903],[99.7328458708514,145.6845186598911]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192511,171.41663917972903],[99.86321687378347,145.68439896056552]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[99.94229459249615,145.68436400280396]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515464,171.41663917972903],[99.98290209995109,145.6843570984625]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.765975628210114,171.41663917972903],[99.99786276247576,145.68435644436695]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751364,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"face_3_slot_0","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[100],"h":1},{"t":59,"s":[100],"h":1},{"t":60,"s":[100],"h":1},{"t":61,"s":[100],"h":1},{"t":62,"s":[100],"h":1},{"t":63,"s":[100],"h":1},{"t":64,"s":[100],"h":1},{"t":65,"s":[100],"h":1},{"t":66,"s":[100],"h":1},{"t":67,"s":[100],"h":1},{"t":68,"s":[100],"h":1},{"t":69,"s":[100],"h":1},{"t":70,"s":[100],"h":1},{"t":71,"s":[100],"h":1},{"t":72,"s":[100],"h":1},{"t":73,"s":[100],"h":1},{"t":74,"s":[100],"h":1},{"t":75,"s":[100],"h":1},{"t":76,"s":[100],"h":1},{"t":77,"s":[100],"h":1},{"t":78,"s":[100],"h":1},{"t":79,"s":[100],"h":1},{"t":80,"s":[100],"h":1},{"t":81,"s":[100],"h":1},{"t":82,"s":[100],"h":1},{"t":83,"s":[100],"h":1},{"t":84,"s":[100],"h":1},{"t":85,"s":[100],"h":1},{"t":86,"s":[100],"h":1},{"t":87,"s":[100],"h":1},{"t":88,"s":[100],"h":1},{"t":89,"s":[100],"h":1},{"t":90,"s":[100],"h":1},{"t":91,"s":[100],"h":1},{"t":92,"s":[100],"h":1},{"t":93,"s":[100],"h":1},{"t":94,"s":[100],"h":1},{"t":95,"s":[100],"h":1},{"t":96,"s":[100],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.00213723752422,145.68435644436695],[175.2340243717899,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.01709790004887,145.68435709846247],[175.21906944484545,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.05770540750385,145.68436400280396],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.1367831262165,145.68439896056552],[175.09975050807492,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.26715412914858,145.6845186598911],[174.97042363235647,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.46164040852113,145.68484083693517],[174.77873551150387,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.73306087154158,145.68557791140813],[174.51367721260826,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.09422703281935,145.68707808794161],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.55793479589938,145.68987390647064],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.13695009169614,145.69473820474883],[173.18694334757186,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.84398541468727,145.70274741992375],[172.54572545701274,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[103.69166347102245,145.71535209549262],[171.79960753081258,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[104.69246323090135,145.73445436416338],[170.94920515022932,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[105.85864266890633,145.76249203264803],[169.99796493107306,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[107.20213139409789,145.8025286847221],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.73438523824998,145.85834892439436],[167.82118509254207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[110.46619371861527,145.9345574790187],[166.61675437660267,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[112.40743016966178,146.03668034701371],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.56673331575352,146.17126547853735],[164.04759992204188,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[116.9511082303112,146.34597959060318],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[119.56543412751468,146.56969661159826],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[122.41186643342385,146.85257189760665],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.48912130867366,147.20609474441147],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.79163252777767,147.6431098261423],[157.58326505555536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[132.30857370939827,148.17779603433607],[156.46115967472625,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[136.02274375594828,148.82558880736312],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.9093204855403,149.60302950658289],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[143.93449736804646,150.52752284250107],[153.9041032849644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[148.05403158786663,151.61698098210726],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[152.21174892054898,152.88933106722158],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.33807262878386,154.36186184207872],[153.2987300604207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[160.34867008765178,156.0503854539823],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.14334216845504,157.96819292461282],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.60531609072166,160.12478711212304],[156.25574276290294,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.60114132569498,162.52438615639866],[158.5968892371398,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.98142804309384,165.16420448884634],[162.0242806171326,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.58270506836902,168.0325385968404],[167.0060458086607,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2307042485341,171.10671188088455],[174.34610750400844,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530165,171.41663917972903],[26.15046474746714,166.49908237253814]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.02171144300978,171.41663917972903],[30.035359501747664,161.95417685080264]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[35.97759368381941,157.9009504134384]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112142,171.41663917972903],[43.524739673823944,154.41502126880033]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.54542176377046,171.41663917972903],[52.23930132816764,151.53412935182934]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513416,171.41663917972903],[61.71757321027329,149.26456770823847]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.28066065919667,171.41663917972903],[71.60176086831838,147.5878230578733]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331905,171.41663917972903],[81.58645024373618,146.4669291932127]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014854,171.41663917972903],[91.42059682997896,145.85220900074034]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76929575146589,171.10671188088457],[100.9061668000921,145.6862229301544],[100.9061668000921,145.6862229301544]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.417294931630963,168.03253859684042],[109.89444817484419,145.9078517026294],[109.89444817484419,145.9078517026294]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.01857195690613,165.16420448884637],[118.28089606093508,146.4555207003181],[118.28089606093508,146.4555207003181]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.398858674304975,162.5243861563987],[125.9992085169768,147.26962670455904],[125.9992085169768,147.26962670455904]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.394683909278314,160.12478711212307],[133.01516676125698,148.29425928080326],[133.01516676125698,148.29425928080326]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.856657831544936,157.96819292461282],[139.32062633606557,149.47832409788663],[139.32062633606557,149.47832409788663]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[39.65132991234821,156.0503854539823],[144.9279202573607,150.77617838683688],[144.9279202573607,150.77617838683688]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.661927371216095,154.36186184207872],[149.86483302362365,152.14788350454043],[149.86483302362365,152.14788350454043]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[47.78825107945101,152.88933106722158],[154.1702249825266,153.55916933063904],[154.1702249825266,153.55916933063904]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[51.94596841213336,151.61698098210726],[157.89032775993167,154.98119240867067],[157.89032775993167,154.98119240867067]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[56.06550263195349,150.52752284250107],[161.07569026230578,156.39015609296857],[161.07569026230578,156.39015609296857]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.09067951445965,149.6030295065829],[163.7787279457848,157.7668476672345],[163.7787279457848,157.7668476672345]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[63.97725624405165,148.82558880736312],[166.05181248302944,159.09613519733574],[166.05181248302944,159.09613519733574]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[67.69142629060164,148.1777960343361],[167.94583184319973,160.36645616899267],[167.94583184319973,160.36645616899267]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.20836747222228,147.6431098261423],[169.5091497314232,161.56932089599852],[169.5091497314232,161.56932089599852]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.5108786913263,147.20609474441147],[170.78689635032543,162.6988462564911],[170.78689635032543,162.6988462564911]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[77.58813356657613,146.85257189760665],[171.8205280053716,163.75132940997366],[171.8205280053716,163.75132940997366]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[80.43456587248531,146.56969661159826],[172.64760001130034,164.72486659520752],[172.64760001130034,164.72486659520752]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[83.04889176968875,146.34597959060318],[173.30170482167705,165.61901871326904],[173.30170482167705,165.61901871326904]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.43326668424646,146.17126547853735],[173.81253471130574,166.43452296328823],[173.81253471130574,166.43452296328823]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[87.59256983033822,146.03668034701371],[174.20603531105112,167.17304813479808],[174.20603531105112,167.17304813479808]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[89.53380628138467,145.9345574790187],[174.50462260185213,167.836990104014],[174.50462260185213,167.836990104014]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.26561476174996,145.85834892439436],[174.72744150919286,168.42930348868117],[174.72744150919286,168.42930348868117]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[92.79786860590211,145.8025286847221],[174.89064897156487,168.95336516806938],[174.89064897156487,168.95336516806938]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[94.14135733109363,145.76249203264803],[175.00770831106672,169.4128653744158],[175.00770831106672,169.4128653744158]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[95.30753676909862,145.73445436416338],[175.08968496955248,169.81172223294573],[175.08968496955248,169.81172223294573]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[96.30833652897755,145.71535209549262],[175.14553626674518,170.1540159102598],[175.14553626674518,170.1540159102598]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.1560145853127,145.70274741992375],[175.18238987263604,170.44393888058158],[175.18238987263604,170.44393888058158]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.8630499083038,145.69473820474883],[175.20580725120632,170.6857592030873],[175.20580725120632,170.6857592030873]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.44206520410059,145.68987390647064],[175.2200295079158,170.88379409754347],[175.2200295079158,170.88379409754347]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.9057729671806,145.68707808794161],[175.22820393443305,171.04239149313332],[175.22820393443305,171.04239149313332]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.26693912845845,145.68557791140813],[175.23259015718264,171.16591759537795],[175.23259015718264,171.16591759537795]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.53835959147887,145.68484083693517],[175.2347452187699,171.25874886104174],[175.2347452187699,171.25874886104174]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.7328458708514,145.6845186598911],[175.23568720142907,171.32526708618758],[175.23568720142907,171.32526708618758]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.86321687378347,145.68439896056552],[175.2360371788439,171.36985659529591],[175.2360371788439,171.36985659529591]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.94229459249615,145.68436400280396],[175.23613938850093,171.3969027679839],[175.23613938850093,171.3969027679839]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.98290209995109,145.6843570984625],[175.2361595754449,171.41079135350373],[175.2361595754449,171.41079135350373]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99786276247576,145.68435644436695],[175.23616148789233,171.41590820144467],[175.23616148789233,171.41590820144467]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0}]} \ No newline at end of file diff --git a/assets/pyramid-loader.svg b/assets/pyramid-loader.svg new file mode 100644 index 0000000..002d574 --- /dev/null +++ b/assets/pyramid-loader.svg @@ -0,0 +1,4 @@ + + + + diff --git a/autopilot.ps1 b/autopilot.ps1 new file mode 100644 index 0000000..5b1abfd --- /dev/null +++ b/autopilot.ps1 @@ -0,0 +1,25 @@ +# Pyramid-Autopilot: startet Claude-Sessions in Endlosschleife. +# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten ein Neustart versucht, +# bis das Kontingent wieder da ist. Stoppen: Strg+C oder Fenster schließen. +# +# Start: Rechtsklick → "Mit PowerShell ausführen" +# oder: pwsh -File autopilot.ps1 + +Set-Location $PSScriptRoot + +$prompt = "Lies CLAUDE.md und arbeite ROADMAP.md Punkt für Punkt weiter ab. " + + "Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). " + + "Arbeite so viele Punkte ab wie möglich." + +while ($true) { + $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + Add-Content autopilot.log "`n===== [$ts] Neue Claude-Session =====" + Write-Host "`n===== [$ts] Neue Claude-Session (Log: autopilot.log) =====" -ForegroundColor Cyan + + claude -p $prompt --dangerously-skip-permissions 2>&1 | Tee-Object -FilePath autopilot.log -Append + + $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + Add-Content autopilot.log "===== [$ts] Session beendet - naechster Versuch in 20 Minuten =====" + Write-Host "===== [$ts] Session beendet - naechster Versuch in 20 Minuten (Strg+C zum Stoppen) =====" -ForegroundColor Yellow + Start-Sleep -Seconds 1200 +} diff --git a/autopilot.sh b/autopilot.sh new file mode 100644 index 0000000..04561cf --- /dev/null +++ b/autopilot.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife. +# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten neu versucht. +# Start (am besten in tmux): tmux new -s pyramid './autopilot.sh' +# Wieder reinschauen: tmux attach -t pyramid +# Stoppen: Strg+C in der tmux-Session +cd "$(dirname "$0")" +export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH" + +PROMPT="Lies CLAUDE.md und arbeite ROADMAP.md Punkt für Punkt weiter ab. \ +Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). \ +Arbeite so viele Punkte ab wie möglich." + +while true; do + echo "===== $(date '+%F %T') Neue Claude-Session =====" | tee -a autopilot.log + claude -p "$PROMPT" --dangerously-skip-permissions 2>&1 | tee -a autopilot.log + echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log + sleep 1200 +done diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md new file mode 100644 index 0000000..511a9f7 --- /dev/null +++ b/docs/NOTIFICATIONS.md @@ -0,0 +1,136 @@ +# Push-Benachrichtigungen & E2EE im Hintergrund + +Stand: 2026-06-04. Dieses Dokument beschreibt, wie Pyramid auf Android +verschlüsselte Push-Benachrichtigungen verarbeitet — inklusive Entschlüsselung +und verschlüsseltem Antworten **bei komplett gekillter App**, ohne dass sich die +App öffnet. + +## Überblick der Kette + +``` +Matrix-Homeserver (Continuwuity) + │ (event_id_only Push) + ▼ +Sygnal (self-hosted: push.steggi-matrix.work) + ▼ +Firebase Cloud Messaging (FCM) + ▼ +PushService.kt (FirebaseMessagingService, nativ) + ├─ App lebt im Vordergrund → App zeigt selbst, Push wird übersprungen + ├─ App lebt im Hintergrund → Haupt-Engine entschlüsselt (warm, schnell) + └─ App gekillt → BgEngine bootet ein headless Flutter-Isolate +``` + +Der Flutter-eigene FCM-Dienst (`FlutterFirebaseMessagingService`) ist im +`AndroidManifest.xml` **deaktiviert** — alle Pushes laufen über `PushService.kt`. +Push-Format ist `event_id_only` (nur die Event-ID wird übertragen, nicht der +Inhalt — datensparsam). + +## Warum dieser Aufbau? + +Die E2EE-Schlüssel (olm/megolm) liegen **nur** im Matrix-Client (verschlüsselte +SQLite-DB), nicht im nativen Kotlin-Code. Kotlin kann daher Sender + Raumname +(unverschlüsselte Room-States) per HTTP holen, aber **nicht** den Nachrichtentext +entschlüsseln. Dafür braucht es einen laufenden Matrix-Client. + +**Sicherheitsregel:** Niemals zwei Matrix-Clients gleichzeitig auf derselben +olm-DB — das korrumpiert den Ratchet-State und führt zu dauerhaften +„Unable to decrypt"-Fehlern. Deshalb läuft der Hintergrund-Client (`BgEngine`) +**nur**, wenn die Haupt-App nachweislich tot ist (`FlutterEngineCache.get("main") +== null`). + +## Die drei Zustände + +| Zustand | Erkennung | Pfad | +|---|---|---| +| Vordergrund | Heartbeat < 20s (`flutter.notif_app_heartbeat`) | Push übersprungen, App zeigt selbst | +| Hintergrund (lebt) | `FlutterEngineCache.get("main") != null` | Platzhalter sofort → Haupt-Engine entschlüsselt + aktualisiert | +| Gekillt | keine Haupt-Engine | `BgEngine` bootet headless Isolate, **decrypt-first** | + +## Gekillter Zustand im Detail (decrypt-first) + +1. `PushService.onMessageReceived` holt Titel (Sender · Raum) per HTTP. +2. Statt sofort einen Platzhalter zu zeigen: + - `BgEngine.scheduleFallback(…, 6s)` plant einen „Neue Nachricht"-Platzhalter, + der **nur** erscheint, falls die Entschlüsselung > 6s dauert oder scheitert. + - `BgEngine.run("decryptAndShow", …)` bootet das Isolate. +3. `BgEngine` (BgEngine.kt) startet eine headless `FlutterEngine` über den + gespeicherten Callback-Handle (`flutter.bg_engine_handle`) und führt das + Dart-Entrypoint `notificationEngineMain` (background_push.dart) aus. +4. Dart `_bgDecryptAndShow`: + - `_buildClient()` öffnet **dieselbe** DB (`pyramid.sqlite`, + `MatrixSdkDatabase.init('pyramid')`, `NativeImplementationsDummy`). + - `client.getEventByPushNotification(...)` entschlüsselt mit den lokal + vorhandenen megolm-Schlüsseln (oneShotSync nur als Fallback). + - Gibt Titel + Text per MethodChannel `showDecrypted` an Native zurück. +5. `BgEngine` zeigt via `NotificationHelper.show` die echte Nachricht und + bricht den Fallback-Platzhalter ab. + +Ergebnis: Die Benachrichtigung erscheint ~4 s nach der Nachricht **direkt mit +echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der +Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s). + +## Verschlüsselt antworten (gekillt, vollständig still) + +- Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`), + **nicht** MainActivity — die App öffnet sich also nicht. +- `ReplyReceiver`: + - App lebt → MethodChannel `replyFromNotification` an die Haupt-Engine. + - Gekillt → `BgEngine.run("sendReply", …)` → Dart `_bgSendReply` → oneShotSync + + `room.sendTextEvent` (verschlüsselt). `goAsync()` + finish nach 15 s hält den + Receiver-Prozess am Leben, bis der Versand durch ist. +- RemoteInput-Extraktion hat mehrere Key-Fallbacks (OEM-Eigenheiten) und + funktioniert auf Samsung S23 trotz der alten „getBroadcast verschluckt + RemoteInput"-Warnung. + +## Gelöste Fallstricke (NICHT wieder einbauen) + +1. **Loader-Reihenfolge:** `FlutterCallbackInformation.lookupCallbackInformation` + erst **nach** `flutterLoader.startInitialization` + `ensureInitializationComplete` + aufrufen — sonst `UnsatisfiedLinkError` (native Methode, libflutter.so noch + nicht geladen). +2. **vodozemac doppelt:** Wenn Decrypt- und Reply-Task dasselbe wiederverwendete + Isolate nutzen, wirft das zweite `vod.init()` „already initialized". In + `_buildClient` in eigenem try/catch schlucken — sonst gibt `_buildClient` + still `null` zurück und die Antwort geht nicht raus. +3. **`database_closed`-Rauschen:** Vor `client.dispose()` 2 s warten, damit der + asynchrone Key-Backup-Upload fertig wird. + +## Relevante Dateien + +| Datei | Rolle | +|---|---| +| `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing | +| `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter | +| `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) | +| `android/.../NotificationHelper.kt` | Native Benachrichtigung (Reply/Tap/Dismiss, setOnlyAlertOnce) | +| `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback | +| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply | +| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal | +| `lib/core/notification_service.dart` | Vordergrund/Hintergrund-Notifs, Heartbeat | +| `lib/main.dart` | `registerBgEngineHandle()` beim Start | + +## Debugging + +`flutter run` verliert die Verbindung, sobald die App gekillt wird — daher direkt +`adb logcat` nutzen: + +``` +adb logcat -v time PYRAMID-PUSH:* PYRAMID-BGENGINE:* PYRAMID-REPLY:* PYRAMID-INTENT:* flutter:* AndroidRuntime:E *:S +``` + +Erwartete Log-Sequenz (gekillt, Erfolg): +``` +PYRAMID-PUSH: decrypt-first via background engine for +PYRAMID-BGENGINE: background engine started +PYRAMID-BGENGINE: Dart background engine ready — flushing 1 task(s) +PYRAMID-BGENGINE: showDecrypted → shown decrypted notification for +``` +Reply (gekillt): +``` +PYRAMID-REPLY: extracted replyText=… (remoteInput=true) +PYRAMID-REPLY: Flutter engine not running → background engine send +PYRAMID-BGENGINE: (sendReply → sendTextEvent OK) +``` + +Hinweis: Kotlin lässt sich nur auf einem Rechner mit JAVA_HOME/Android-SDK bauen. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md new file mode 100644 index 0000000..2c79278 --- /dev/null +++ b/docs/PROGRESS.md @@ -0,0 +1,43 @@ +# Pyramid – Fortschritt & offene Punkte + +Stand: 2026-06-04 + +## Erledigt + +### Chat / UI +- **Reply-Zitat-Karte** im Discord/WhatsApp-Stil (kein „In reply to @user" mehr), medien-bewusste Vorschau, getönter Hintergrund bündig mit Akzent-Strich. +- **Per-Message-Selektion & -Hover** — einzelne Nachrichten statt ganzer Sender-Gruppen werden markiert/hervorgehoben. +- **Scroll-Stabilität** — Crash durch doppelte Keys behoben, Pagination (kein endloses „Zurückziehen", lädt aber bis zum echten Anfang), Bild-/PDF-Platz wird vorab reserviert (kein Springen beim Laden). +- **„Neue Nachrichten"-Divider** — korrekte Position, scrollt zuverlässig hin, blendet beim Erreichen des Endes aus; erscheint nur bei echten ungelesenen Nachrichten von anderen. +- **Composer-Höhe** bündig mit dem Profil-Block; **Verbindungs-Statusbanner** bei Sync-Verlust; **fehlgeschlagene Nachrichten** bleiben mit „Wiederholen/Löschen" stehen statt zu verschwinden. +- **„Zuletzt online"** nutzt echten Presence-Status + Frische statt `currentlyActive` (kein falsches „online" mehr). +- **Members-Panel** mobil schließbar + schmaler. + +### Medien & Dokumente +- **Media-Cache** (LRU, byte-budgetiert, geteilt) — zuletzt betrachtete Medien sofort da, ältere automatisch verworfen. +- **Datei-Vorschau** — höhere Auflösung (PNG statt JPEG), viele neue Text/Code/Daten-Formate, **immersiver Fullscreen-Viewer** (Pan, +/− 20%-Zoom, Ctrl+Mausrad, Seiten-Navigation, mobil-optimiert, Tap blendet UI aus). +- **Eingebettete Link-Vorschauen** (Open-Graph-Karten) — serverseitig in Continuwuity aktiviert + Client-Karte. + +### Suche +- **Lokale Suche** über entschlüsselte Events (server-seitige `/search` funktioniert nicht für E2EE) — Nachrichten, Medien (mit **Thumbnail**), Personen/Räume. +- **Sprung zur Nachricht** aus dem Suchergebnis inkl. Cross-Chat, mit sanft ausblendendem Highlight. + +### Benachrichtigungen (Android) +- Bei **gekillter App**: Hintergrund-Entschlüsselung (decrypt-first) + **verschlüsseltes Antworten** ohne App-Öffnen. Details siehe [NOTIFICATIONS.md](NOTIFICATIONS.md). + +### Einstellungen +- **Mobile Settings** im Element-Stil: Kategorie-Liste → Untermenü mit Zurück-Pfeil (statt gedrängtem Icon-Block). Desktop unverändert. + +### Server (Pi 5, steggi@192.168.178.71) +- rclone-Mount für Pixeldrain-Medien repariert + Boot-Reihenfolge abgesichert; Medien liegen nur auf Pixeldrain. +- URL-Preview-Allowlist in `conduit.toml` aktiviert. + +### Lesebestätigungen (Element-Stil, mit Haken) +- **✓✓** an der zuletzt gesehenen Nachricht des Gegenübers (auch dessen eigener Antwort), berechnet aus `room.receiptState`. Wandert mit, statt an der eigenen letzten Antwort zu kleben. +- **grauer ✓** nur auf der eigenen neuesten Nachricht, solange sie noch ungelesen ist; verschwindet sobald gelesen (dann nur ✓✓). + +## Offen / geplant +- **Weiterleiten** von Nachrichten (Forward) — später. +- **Presence-Genauigkeit:** Nutzer, die den PC vor ~10 min unsauber heruntergefahren haben, werden teils noch als „online" angezeigt (Server expired Presence nicht zuverlässig). Außerdem: Online-Status im Chat-Header und in der Chat-Liste sind nicht immer konsistent — sollten dieselbe Quelle nutzen. +- PDF-Seiten beim Upload vorrendern — bewusst zurückgestellt (geringer Mehrwert wegen Caches). +- Selection-Menü-Politur (kein Bug, optional). diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index c80469e..dfd4303 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -1,6 +1,19 @@ # Matrix / Element Feature Checklist für Pyramid *Stand: April 2026 — Pyramid v0.1.x (Early Development)* +*Letzte Tiefenanalyse: 2026-04-29 (Vergleich mit FluffyChat / Element Web)* + +--- + +## Legende + +- ✅ `[x]` — Implementiert und funktioniert korrekt +- ⚠️ `[~]` — Teilweise / fehlerhaft implementiert (Details in Technische Analyse) +- ❌ `[ ]` — Fehlt komplett +- 🔴 P0 — Kritisch (Kernfunktionalität kaputt) +- 🟠 P1 — Hoch (wichtige Funktion defekt oder nicht vorhanden) +- 🟡 P2 — Mittel +- 🔵 P3 — Niedrig / Nice-to-have --- @@ -15,17 +28,19 @@ - [x] Verschlüsselte Nachrichten anzeigen (Entschlüsselungsstatus) - [x] Nachrichtenverlauf paginieren (ältere Nachrichten beim Scrollen laden) - [x] Ungelesene-Nachrichten-Tracking +- [~] Nachrichtenbearbeitung (Edit-Event erkannt, `getDisplayEvent` wird nicht gerendert) 🟠 P1 +- [~] Nachrichtenvorschau bei Reply (Zitat-Preview vorhanden, kein Jump-to-original) 🟡 P2 ### 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) +- [x] Auf Nachrichten antworten (in-reply-to) - [ ] Nachrichten weiterleiten - [ ] Zitat-Styling (visuelle Blockquote-Anzeige) -### Textformatierung +### Textformatierung (formatted_body) +- [~] Markdown-Rendering — `formatted_body` / `org.matrix.custom.html` wird nicht gesendet und nicht gerendert 🔵 P3 - [ ] Fett (**text**) - [ ] Kursiv (*text*) - [ ] Durchgestrichen (~~text~~) @@ -33,7 +48,7 @@ - [ ] Code-Block (```code```) - [ ] Aufzählungslisten - [ ] Klickbare Links -- [ ] @Erwähnungen (User) +- [~] @Erwähnungen — Button vorhanden, `onTap: null` 🟡 P2 - [ ] #Kanal-Erwähnungen - [ ] Spoiler-Tags (||text||) @@ -62,14 +77,118 @@ - [x] Mehrere Reaktionen pro Nachricht - [x] Kategoriebasierte Emoji-Organisation +### Tipp-Indikatoren (Typing) +- [~] `room.setTyping()` wird **nirgendwo aufgerufen** — Einstellung existiert, aber Composer sendet nie "tippt…" 🟡 P2 +- [ ] "X tippt gerade"-Anzeige empfangen (kein Widget) +- [ ] Timeout nach Tipp-Ende + ### Threads -- [ ] Thread-Antworten (m.in_reply_to vorhanden, kein Thread-View) +- [~] Thread-Button vorhanden (`onPressed: () {}` — leer) 🔵 P3 - [ ] Thread aufklappen / eigene Ansicht - [ ] Thread-Benachrichtigungen - [ ] Thread als gelöst markieren --- +## Lesebestätigungen (Read Receipts) + +- [x] Senden von `m.read`-Receipts via `room.setReadMarker()` +- [x] Privacy-Toggle (kein Senden wenn deaktiviert) +- [x] ✓-Icon (Nachricht gesendet/zugestellt) auf eigenen Nachrichten +- [x] ✓✓-Icon (gelesen) in DMs +- [x] Sende-Status (Uhr/Fehler/gesendet/gelesen) +- [~] ✓✓-Logik nur für DMs — in Gruppenräumen wird `receiptState.global.otherUsers` nicht ausgewertet 🟡 P2 +- [~] **Off-by-one**: Divider erscheint *über* der Gruppe die das `fullyRead`-Event enthält, nicht darunter 🟡 P2 +- [ ] Gruppen-Read-Receipt-Display (Seen-by-Avatare wie in FluffyChat) 🟡 P2 +- [ ] Hover-Tooltip "Gelesen von: ..." auf ✓✓ +- [ ] **Notification-Cancellation**: `cancelNotification(roomId)` beim Setzen des Read-Markers 🟡 P2 + +--- + +## „Neue Nachrichten" Trennlinie + +- [x] `_UnreadDivider`-Widget korrekt gestylt +- [x] `_sessionFullyReadId` einmalig beim Öffnen erfasst (stabil für die Session) +- [x] Keine Anzeige wenn Raum bereits gelesen +- [~] **Position falsch** — Divider über der Gruppe mit `fullyRead`-Event statt darunter 🟡 P2 +- [ ] **Auto-Scroll zum Divider** beim Öffnen eines Raums mit ungelesenen Nachrichten 🟡 P2 + +--- + +## Presence / Zuletzt Online + +- [x] `_PresenceLine` im DM-Header mit Live-Updates +- [x] 4s-Debounce gegen Sync-Spike-Fehlmeldungen +- [x] Relative Zeitanzeige (gerade eben / vor Xmin / vor Xh) +- [x] Guard: eigene Presence wird nie angezeigt +- [x] Presence-Dot im Rooms-Panel und Members-Panel +- [x] `setPresence(online/offline)` in den Settings +- [~] **Kein Auto-Heartbeat** — `setPresence` nur in Settings, nie periodisch während Nutzung 🔵 P3 +- [~] **Kein `WidgetsBindingObserver`** — App-Lifecycle (Hintergrund/Vordergrund) löst kein Presence-Event aus 🔵 P3 +- [ ] Presence-Dot in DM-Listen-Items (`room_list_item.dart`) 🔵 P3 + +--- + +## Benachrichtigungen + +### Local Notifications (App im Vordergrund) +- [x] Android Local Notifications (`flutter_local_notifications`) +- [x] Windows Toast-Notifications (`local_notifier`) +- [x] Windows Taskbar-Badge (Progress-Error-Mode) +- [x] Tap-Handler navigiert zum richtigen Raum (Android) +- [x] Guards: nur fremde Nachrichten, nur aktive Rooms, nur `Membership.join` +- [x] `notificationsBlockedProvider` + Banner in `app_shell.dart` +- [~] **Alle 4 Notification-Prefs werden ignoriert** — `notifDesktopProvider`, `notifSoundProvider`, `notifPreviewProvider`, `notifMentionOnlyDmProvider` haben keinen Effekt 🟠 P1 +- [~] Android-Grouping ohne Summary-Notification 🔵 P3 + +### Remote Push (App im Hintergrund / geschlossen) +- [ ] 🔴 **Keine Remote-Push-Integration** — FCM/UnifiedPush fehlt komplett, App muss laufen +- [ ] iOS APNs-Setup +- [ ] macOS-Notifications +- [ ] Android Icon-Badge (Zahlenbadge) + +--- + +## E2EE Verschlüsselungs-Pipeline + +### Implementiert +- [x] Vodozemac (Rust-Olm) via Flutter-Isolate — modern, performant +- [x] Bootstrap: SSSS, Cross-Signing, Online-Key-Backup +- [x] `_decryptLegacyEvents` — entschlüsselt ältere Events in-place +- [x] Device-Key-Preload vor erstem Senden +- [x] Key-Verification UI (SAS Emoji, QR-Code, alle States) +- [x] Megolm Session Export/Import (Element-kompatibles Format) +- [x] Encrypted-Attachment-Handling +- [x] `m.fully_read`-Marker beim Timeline-Open +- [x] `timeline.requestKeys(onlineKeyBackupOnly: false)` + +### Fehlerhaft / Fehlend +- [~] **"Entschlüsselt…"-Placeholder bleibt dauerhaft** wenn Key fehlt — kein Recovery-Button 🟠 P1 +- [~] `loadAllKeysFromRoom` Fehler werden silent geschluckt (`catch (_) {}`) 🟡 P2 +- [~] **Kein Incoming-Verification-Request-Handler** — `client.onKeyVerificationRequest` wird nicht abonniert 🔴 P0 +- [~] **Doppelte Bootstrap-Implementierung** (page vs. dialog) mit leicht inkonsistenter Logik 🔵 P3 +- [ ] Device-Trust-Indicator in der Timeline 🔵 P3 +- [ ] Unverified-Device-Warning beim Senden 🔵 P3 +- [ ] "Schlüssel anfordern"-Button bei dauerhaft verschlüsselten Events 🟠 P1 + +--- + +## Suche + +- [x] Suchdialog öffnen (UI vorhanden) +- [x] Server-seitige Volltextsuche via Matrix `/search` API +- [x] Filter: Alle / Medien / Personen +- [x] Ergebnisse: Raum + Absender + Body + Zeitstempel +- [x] Keyboard-Navigation in Ergebnissen +- [x] 400ms Debounce +- [ ] Jump-to-Message bei Klick auf Suchergebnis +- [ ] Suche nach Absender filtern +- [ ] Filter nach Datum +- [ ] Suchergebnisse im Chat hervorheben +- [ ] Suchverlauf + +--- + ## Raumverwaltung ### Grundfunktionen @@ -79,14 +198,12 @@ - [x] Raumname & Avatar anzeigen - [x] Verschlüsselungsstatus (Schloss-Icon) - [x] Einladungen annehmen / ablehnen -- [ ] Neuen Raum erstellen +- [ ] Neuen Raum erstellen (UI-Einstiegspunkt) - [ ] 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 @@ -94,261 +211,214 @@ - [x] Echtzeit-Presence-Updates - [x] Sprachanruf aus DM starten - [x] Videoanruf aus DM starten -- [ ] DM starten (neuen Chat beginnen, nur über Suchfeld) +- [ ] DM starten (neuen Chat beginnen) ### Mitglieder & Rollen - [x] Mitgliederliste anzeigen - [x] Mitgliederanzahl anzeigen - [x] Avatar & Anzeigename der Mitglieder -- [x] Nutzer in Raum einladen (Raum-Kontextmenü) +- [x] Nutzer in Raum einladen - [ ] 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] Space-Avatar & Banner anzeigen - [x] Räume nach Space filtern -- [x] Virtuelle "Rooms"- und "DMs"-Spaces -- [x] Unterkategorien (Sub-Spaces) als Kategorien in der Kanalleiste +- [x] Unterkategorien (Sub-Spaces) - [x] Drag-and-drop Kanalreihenfolge für Admins -- [x] Space-Verwaltungsdialog (Name, Avatar, Banner, Einladungen, Mitglieder, Power Levels) +- [x] Space-Verwaltungsdialog - [ ] 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 +- [ ] Space-Entdeckung (öffentliche Spaces) --- -## Sprach- & Videoanrufe +## Sprach- & Videoanrufe (LiveKit) -### Sprachanrufe (LiveKit) -- [x] Sprachkanal betreten / verlassen (Toggle) +- [x] Sprachkanal betreten / verlassen +- [x] Mikrofon stummschalten / Deafen - [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 +- [x] Anruf-UI (Vollansicht + Mini-Widget) +- [x] Videoanruf mit Kamera-Toggle - [ ] 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 +- [ ] Bildschirmfreigabe --- ## 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 +- [x] Hell / Dunkel-Theme, Akzentfarbe, Eckradius, Dichte +- [x] Eigene User-ID, Avatar (Initialen-Fallback), Anzeigename anzeigen +- [ ] Anzeigenamen & Avatar bearbeiten - [ ] 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 +- [ ] Sitzungsverwaltung +- [ ] Konto deaktivieren --- ## 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] Versions-Vergleich (SemVer), Rate-Limiting, Update-Banner - [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) +- [ ] Automatischer Download & Installation --- ## 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 +### Desktop +- [x] Native Desktop-App, Tastenkürzel, Drag-and-Drop - [ ] 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 +- [x] Mobile App (Flutter), Drawer-Navigation +- [ ] Touch-optimiertes UI - [ ] Mobile Push-Benachrichtigungen -- [ ] Zurück-Geste Navigation -- [ ] Bottom-Sheet-Modals -- [ ] Share-Sheet-Integration -- [ ] Haptisches Feedback + +--- + +--- + +# Technische Tiefenanalyse (2026-04-29) + +*Vergleichsbasis: FluffyChat (krille-chan/fluffychat) und Element Web* +*Format: Befund → betroffene Datei:Zeile → Referenz-Implementierung* + +--- + +## IMPLEMENTIERUNGS-BACKLOG (nach Priorität) + +### 🔴 P0 — Kritisch + +#### [P0-1] Incoming Key-Verification-Request-Handler fehlt +**Problem:** Wenn ein anderes Gerät eine Verifikations-Anfrage (`m.key.verification.request`) an Pyramid schickt, gibt es keinen Listener. Die Anfrage läuft ab, ohne dass der Nutzer sie sieht. Das bedeutet: andere Geräte können Pyramid de facto nicht verifizieren — nur Pyramid kann aktiv verifizieren. +**Betroffene Datei:** `lib/widgets/settings_modal.dart` (kein `onKeyVerificationRequest`-Listener) +**FluffyChat-Referenz:** `lib/pages/key_verification/key_verification_dialog.dart` + `client.onKeyVerificationRequest.stream.listen(...)` in `lib/pages/matrix.dart` +**Fix:** Stream auf `client.onKeyVerificationRequest` in `matrix_client.dart` oder `app_shell.dart` subscriben und `KeyVerificationDialog` öffnen. + +--- + +### 🟠 P1 — Hoch + +#### [P1-1] Notification-Prefs werden ignoriert +**Problem:** `notifDesktopProvider`, `notifSoundProvider`, `notifPreviewProvider`, `notifMentionOnlyDmProvider` sind in `settings_prefs.dart` gespeichert, werden in `notification_service.dart/_watchEvents()` aber nie ausgelesen. Notifications erscheinen immer, unabhängig von Nutzereinstellungen. +**Betroffene Datei:** `lib/core/notification_service.dart`, Zeile 145–168 +**Fix:** In `_watchEvents()` die Prefs vor `showMessageNotification()` prüfen. + +#### [P1-2] Edits werden nicht gerendert +**Problem:** `_isVisible()` filtert Edit-Events korrekt heraus, aber `event.getDisplayEvent(timeline)` wird nirgendwo aufgerufen. Nachrichten zeigen immer die Originalversion, auch wenn sie via `m.replace` bearbeitet wurden. Ein `(bearbeitet)`-Label fehlt ebenfalls. +**Betroffene Datei:** `lib/features/chat/message_group.dart` — Event-Rendering-Bereich +**FluffyChat-Referenz:** `event.getDisplayEvent(timeline)` vor dem Rendern jedes Events +**Fix:** Beim Rendern von Events `event.getDisplayEvent(timeline)` statt `event` verwenden; `(bearbeitet)`-Label wenn `event != displayEvent`. + +#### [P1-3] Encrypted-Placeholder bleibt dauerhaft — kein Recovery-Button +**Problem:** `EventTypes.Encrypted` zeigt "Nachricht wird entschlüsselt…" dauerhaft wenn die Entschlüsselung scheitert. Es gibt keinen Button zum Anfordern fehlender Keys. +**Betroffene Datei:** `lib/features/chat/message_group.dart`, Zeilen ~408–421 +**FluffyChat-Referenz:** `_requestMissingKey()` mit `room.client.sendToDeviceEncrypted(...)` / SDK-`requestKey()` +**Fix:** Wenn `event.status == EventStatus.error` oder nach Timeout immer noch `EventTypes.Encrypted`: "Schlüssel anfordern"-Button anzeigen, der `timeline.requestKeys()` aufruft. + +--- + +### 🟡 P2 — Mittel + +#### [P2-1] Typing-Indikator: `room.setTyping()` nie aufgerufen +**Problem:** `privacyTypingProvider` existiert, `chat_composer.dart` ruft aber nie `room.setTyping()` auf. Andere Nutzer sehen nie "X tippt gerade…". +**Betroffene Datei:** `lib/features/chat/chat_input.dart` (oder `chat_composer.dart`) +**FluffyChat-Referenz:** `onInputBarChanged` → 2s Debounce → `room.setTyping(true, timeout: 30000)`; bei Senden/Leerfeld: `room.setTyping(false)` +**Fix:** Im Composer-Input `onChanged`-Handler mit Debounce und `room.setTyping()` ergänzen. + +#### [P2-2] Typing-Indikator: Kein Display-Widget +**Problem:** Kein Widget das `room.typingUsers` anzeigt. +**Fix:** `_TypingIndicator`-Widget in `chat_view.dart` unter der Nachrichtenliste einfügen, das `room.onUpdate`-Stream abonniert und `room.typingUsers` anzeigt. + +#### [P2-3] Unread-Divider: Position off-by-one +**Problem:** Divider erscheint *über* der Gruppe die das `fullyRead`-Event *enthält*, nicht darunter. Das bereits-gelesene Event liegt unter dem "Neue Nachrichten"-Strich. +**Betroffene Datei:** `lib/features/chat/chat_view.dart`, Zeile ~715 +**Fix:** Divider nach der Gruppe einsetzen, die das `fullyReadId`-Event enthält, nicht davor. + +#### [P2-4] Unread-Divider: Kein Auto-Scroll beim Öffnen +**Problem:** Beim Öffnen eines Raums mit ungelesenen Nachrichten landet die View unten, nicht beim Divider. +**FluffyChat-Referenz:** Initialer `scrollController.jumpTo()` zum Index des `fullyRead`-Events nach dem ersten Frame. +**Fix:** In `initState`/`WidgetsBinding.addPostFrameCallback` zum Divider-Index springen wenn `_sessionHasUnread`. + +#### [P2-5] Gruppen-Read-Receipts fehlen +**Problem:** ✓✓-Logik greift nur bei `room.isDirectChat`. In Gruppenräumen sehen eigene Nachrichten nie ✓✓. +**FluffyChat-Referenz:** `SeenByRow` zeigt Avatare aller Leser unter der letzten Nachricht. +**Fix:** Receipt-Prüfung auch in Gruppenräumen aktivieren; ggf. Avatar-Reihe unter letzter Nachricht. + +#### [P2-6] Notification-Cancellation beim Lesen fehlt +**Problem:** Android-Notifications bleiben in der Benachrichtigungsleiste stehen, auch wenn der Chat geöffnet und die Nachrichten gelesen wurden. +**FluffyChat-Referenz:** `backgroundPush?.cancelNotification(roomId)` in `_markRead()` +**Fix:** In `_markRead()` in `chat_view.dart` die Android-Notification für `roomId` canceln. + +--- + +### 🔵 P3 — Niedrig + +#### [P3-1] Presence: Kein Auto-Heartbeat +**Problem:** `setPresence(online)` nur in Settings — Server setzt Client nach Timeout offline. +**Fix:** Periodischer Timer (alle 60s) der `setPresence(online)` sendet solange App im Vordergrund. + +#### [P3-2] Presence: Kein App-Lifecycle-Hook +**Problem:** Kein `WidgetsBindingObserver` — kein `unavailable` bei App-Hintergrund. +**Fix:** `WidgetsBindingObserver.didChangeAppLifecycleState` in `matrix_client.dart` oder `app_shell.dart`. + +#### [P3-3] Markdown/`formatted_body` nicht gerendert +**Problem:** `sendTextEvent` sendet nur plain text. Empfangene `formatted_body`-Felder werden nicht gerendert. +**Fix:** Empfang: `flutter_html` oder `flutter_markdown` für `formatted_body`; Senden: simple Markdown-Erkennung im Composer. + +#### [P3-4] `requestFuture()` fehlt +**Problem:** Kein Forward-Pagination-Mechanismus. Nach Jump-to-Event in der Vergangenheit kein Weg zurück. +**Fix:** `timeline.requestFuture()` im Scroll-Listener für die Vorwärts-Richtung. + +#### [P3-5] `message_bubble.dart` — ungenutzter Code +**Problem:** Veraltete WhatsApp-Style-Implementation koexistiert neben aktiver Discord-Style-Implementierung. +**Fix:** Datei löschen oder auskommentieren. --- ## Statistik -| Bereich | Implementiert | Teilweise | Fehlend | +| Bereich | Implementiert | Fehlerhaft | 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** | +| Chat-Mechanik | Solide Basis | Edits, Typing, `requestFuture` | Jump-to-quote, Threads, Edited-Badge | +| Read Receipts | DM-Logik ✓ | Gruppen, Off-by-one, Notif-Cancel | Seen-by-Avatare, Tooltip | +| Presence | Anzeige ✓ | Broadcasting, Lifecycle | Heartbeat, Dot in Raumliste | +| Benachrichtigungen | Local ✓ | Prefs ignoriert, Grouping | Remote Push, iOS, macOS | +| Unread-Divider | Konzept ✓ | Position, Auto-Scroll | — | +| E2EE | Stärkster Bereich | Incoming-Verif., Placeholder | Device-Trust-Indicator, Warn beim Senden | +| Suche | API-Basis ✓ | — | Jump-to-message, Filter | -**Implementierungsgrad: ~42 %** (inkl. Teilimplementierungen) +**Implementierungsgrad gesamt: ~42 %** (inkl. Teilimplementierungen) --- -## Empfohlene Feature-Prioritäten +## Implementierungs-Fortschritt -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 +| ID | Beschreibung | Status | +|---|---|---| +| P0-1 | Incoming Key-Verification-Request-Handler | ✅ War bereits implementiert (`app_shell.dart`) | +| P1-1 | Notification-Prefs auswerten | ✅ Implementiert (`notification_service.dart`) | +| P1-2 | Edits rendern (`getDisplayEvent`) | ✅ Implementiert (`message_group.dart`) | +| P1-3 | Encrypted-Placeholder + Recovery-Button | ✅ Implementiert (`message_group.dart`) | +| P2-1 | Typing: `room.setTyping()` senden | ✅ Implementiert (`chat_composer.dart`) | +| P2-2 | Typing: Display-Widget | ✅ Implementiert (`chat_view.dart` — `_TypingIndicator`) | +| P2-3 | Unread-Divider: Position + leere fullyRead-Guard | ✅ Neu implementiert (`chat_view.dart`) | +| P2-4 | Unread-Divider: Auto-Scroll (Schätzung) | ✅ Implementiert (`_scrollToUnread`) | +| P2-5 | Gruppen-Read-Receipts (DM-Guard entfernt) | ✅ Implementiert (`chat_view.dart`) | +| P2-6 | Notification-Cancellation beim Lesen | ✅ Implementiert (`chat_view.dart/_markRead`) | +| P3-1 | Presence: Auto-Heartbeat (10s) | ✅ Implementiert (`app_shell.dart`) | +| P3-2 | Presence: App-Lifecycle-Hook | ✅ Implementiert (`app_shell.dart` — `WidgetsBindingObserver`) | +| P3-3 | HTML/Markdown (`_MatrixHtmlText`, `_PlainLinkText`) | ✅ Implementiert (`message_group.dart`) | +| P3-4 | `requestFuture()` | ✅ Implementiert (`chat_view.dart/_onScroll`) | +| P3-5 | `message_bubble.dart` entfernen | ⏳ Offen | diff --git a/docs/matrix-sdk/01-auth.md b/docs/matrix-sdk/01-auth.md new file mode 100644 index 0000000..1aaaa52 --- /dev/null +++ b/docs/matrix-sdk/01-auth.md @@ -0,0 +1,241 @@ +# 01 — Authentifizierung & Session + +--- + +## Inhaltsverzeichnis +- [Login mit Passwort](#login-mit-passwort) +- [Login mit Token](#login-mit-token) +- [SSO / OAuth](#sso--oauth) +- [Register](#registrierung) +- [Session prüfen & wiederherstellen](#session-prüfen--wiederherstellen) +- [Logout](#logout) +- [Multi-Account / Device](#multi-account--device) +- [Homeserver ermitteln](#homeserver-ermitteln) +- [Flows & Login-Typen abfragen](#flows--login-typen-abfragen) + +--- + +## Login mit Passwort + +```dart +await client.checkHomeserver(Uri.parse('https://matrix.example.org')); + +final loginResponse = await client.login( + LoginType.mLoginPassword, + password: 'geheimesPasswort', + identifier: AuthenticationUserIdentifier(user: '@alice:example.org'), + // oder nur Username (ohne Server): + // identifier: AuthenticationUserIdentifier(user: 'alice'), + initialDeviceDisplayName: 'Pyramid Android', +); +// loginResponse.accessToken, .deviceId, .userId +``` + +--- + +## Login mit Token + +```dart +await client.checkHomeserver(Uri.parse('https://matrix.example.org')); + +await client.login( + LoginType.mLoginToken, + token: 'einmaliger_login_token', + initialDeviceDisplayName: 'Pyramid', +); +``` + +--- + +## SSO / OAuth + +```dart +// Verfügbare Login-Flows abfragen +final flows = await client.getLoginFlows(); +final hasSso = flows.flows?.any((f) => f.type == AuthenticationTypes.sso) ?? false; + +// SSO-URL erzeugen (öffnet Browser/WebView) +final redirectUri = 'io.pyramid.app://login-callback'; +final ssoUrl = client.homeserver!.toString() + + '/_matrix/client/v3/login/sso/redirect?redirectUrl=${Uri.encodeComponent(redirectUri)}'; +// → Uri im Browser öffnen, nach Redirect den Token extrahieren + +// Nach Redirect mit Token einloggen +await client.login( + LoginType.mLoginToken, + token: tokenAusRedirectUrl, + initialDeviceDisplayName: 'Pyramid', +); +``` + +--- + +## Registrierung + +```dart +// Homeserver-Registrierung prüfen +await client.checkHomeserver(Uri.parse('https://matrix.example.org')); + +// UIAA-Flow starten +try { + await client.uiaRequestBackground( + (auth) => client.register( + username: 'neuernutzer', + password: 'sicheresPasswort', + initialDeviceDisplayName: 'Pyramid', + auth: auth, + ), + ); +} on MatrixException catch (e) { + // e.error: M_USER_IN_USE, M_INVALID_USERNAME, etc. +} +``` + +--- + +## Session prüfen & wiederherstellen + +```dart +// Nach client.init() prüfen ob Session noch gültig ist +if (client.isLogged()) { + print('Eingeloggt als: ${client.userID}'); + print('Homeserver: ${client.homeserver}'); + print('Device ID: ${client.deviceID}'); + print('Access Token: ${client.accessToken}'); +} + +// Auf Login-State reagieren +client.onLoginStateChanged.stream.listen((LoginState state) { + switch (state) { + case LoginState.loggedIn: // Session aktiv + case LoginState.softLoggedOut: // Token abgelaufen, Re-Auth nötig + case LoginState.loggedOut: // Ausgeloggt + } +}); +``` + +--- + +## Logout + +```dart +// Nur dieses Device +await client.logout(); + +// Alle Devices +await client.logoutAll(); +``` + +--- + +## Multi-Account / Device + +```dart +// Eigene Devices (Sessions) auflisten +final devices = await client.getDevices(); +for (final device in devices ?? []) { + print('${device.deviceId}: ${device.displayName} — ${device.lastSeenIp}'); +} + +// Device umbenennen +await client.updateDevice( + deviceId: 'DEVICEID', + displayName: 'Pyramid (Wohnzimmer)', +); + +// Device löschen (andere Session killen) +await client.deleteDevice( + 'ANDERES_DEVICE_ID', + auth: AuthenticationPassword( + identifier: AuthenticationUserIdentifier(user: client.userID!), + password: 'meinPasswort', + ), +); + +// Mehrere Devices auf einmal löschen +await client.deleteDevices( + ['DEVICE1', 'DEVICE2'], + auth: AuthenticationPassword(...), +); +``` + +--- + +## Homeserver ermitteln + +```dart +// .well-known auflösen (Autodiscovery) +final discovery = await client.checkHomeserver( + Uri.parse('https://example.org'), // Domain reicht, SDK findet Matrix-Server +); +// client.homeserver ist jetzt gesetzt + +// Direkt mit bekannter HS-URL +await client.checkHomeserver(Uri.parse('https://matrix.example.org')); + +// Homeserver-Versioninfo +final serverVersion = await client.getVersions(); +print(serverVersion.versions); // ['v1.1', 'v1.2', ...] +``` + +--- + +## Flows & Login-Typen abfragen + +```dart +final response = await client.getLoginFlows(); +for (final flow in response.flows ?? []) { + switch (flow.type) { + case AuthenticationTypes.password: // m.login.password + case AuthenticationTypes.sso: // m.login.sso + case AuthenticationTypes.token: // m.login.token + case 'm.login.cas': // Legacy-CAS + } +} + +// SSO-Provider Details (für Brand-Buttons im UI) +final ssoProviders = response.flows + ?.where((f) => f.type == AuthenticationTypes.sso) + .expand((f) => f.identityProviders ?? []) + .toList(); +// ssoProvider.id, .name, .brand, .icon +``` + +--- + +## UIAA (User-Interactive Auth) Flows + +```dart +// Generisches UIAA-Handling (z.B. für Account-Löschung) +await client.uiaRequestBackground( + (auth) => client.deactivateAccount(auth: auth, eraseData: false), +); + +// Passwort ändern (braucht Re-Auth) +await client.uiaRequestBackground( + (auth) => client.changePassword( + 'neuesPasswort', + auth: auth, + oldPassword: 'altesPasswort', // optional, falls Server es erlaubt + ), +); +``` + +--- + +## Fehlerbehandlung + +```dart +try { + await client.login(...); +} on MatrixException catch (e) { + switch (e.error) { + case MatrixError.M_FORBIDDEN: // Falsches Passwort + case MatrixError.M_USER_IN_USE: // Username vergeben + case MatrixError.M_INVALID_USERNAME: + case MatrixError.M_LIMIT_EXCEEDED: // Rate-Limiting + case MatrixError.M_UNKNOWN: + } + print(e.errorMessage); // Menschenlesbarer Text vom Server +} +``` diff --git a/docs/matrix-sdk/02-rooms.md b/docs/matrix-sdk/02-rooms.md new file mode 100644 index 0000000..d205500 --- /dev/null +++ b/docs/matrix-sdk/02-rooms.md @@ -0,0 +1,436 @@ +# 02 — Räume & Spaces + +--- + +## Inhaltsverzeichnis +- [Raum erstellen](#raum-erstellen) +- [Raum beitreten](#raum-beitreten) +- [Raum verlassen](#raum-verlassen) +- [Raumliste](#raumliste) +- [Raum-Metadaten lesen](#raum-metadaten-lesen) +- [Raum-Einstellungen ändern](#raum-einstellungen-ändern) +- [Mitglieder](#mitglieder) +- [Einladen & Kick & Ban](#einladen--kick--ban) +- [Öffentliches Raumverzeichnis](#öffentliches-raumverzeichnis) +- [Spaces](#spaces) +- [Room Upgrades](#room-upgrades) + +--- + +## Raum erstellen + +```dart +// Einfacher privater Raum +final roomId = await client.createRoom( + name: 'Mein Raum', + topic: 'Willkommen!', + visibility: Visibility.private, // oder .public + preset: CreateRoomPreset.privateChat, // oder .publicChat, .trustedPrivateChat +); + +// Raum mit Einladungen +final roomId = await client.createRoom( + name: 'Projekt Alpha', + invite: ['@bob:example.org', '@charlie:example.org'], + enableEncryption: true, // E2EE aktivieren +); + +// Raum mit Alias +final roomId = await client.createRoom( + name: 'Öffentlicher Kanal', + roomAliasName: 'kanal', // → #kanal:example.org + visibility: Visibility.public, + preset: CreateRoomPreset.publicChat, +); + +// Raum mit Custom Power Levels +final roomId = await client.createRoom( + name: 'Moderiert', + powerLevelContentOverride: PowerLevelsContent( + eventsDefault: 50, // Nur Moderatoren senden + usersDefault: 0, + users: {'@admin:example.org': 100}, + ), +); + +// Raum mit History-Visibility +final roomId = await client.createRoom( + initialState: [ + StateEvent( + type: EventTypes.HistoryVisibility, + content: {'history_visibility': 'invited'}, // shared|invited|joined|world_readable + ), + ], +); +``` + +--- + +## Raum beitreten + +```dart +// Per Raum-ID +await client.joinRoom('!roomId:server'); + +// Per Alias +await client.joinRoomOrAlias('#kanal:example.org'); + +// Per Invite-Link / Matrix-URI +// matrix:r/kanal:example.org +// https://matrix.to/#/#kanal:example.org +final uri = MatrixUri.parse('https://matrix.to/#/#kanal:example.org'); +if (uri != null) { + await client.joinRoomOrAlias(uri.primaryIdentifier); +} + +// Mit Via-Servern (für föderierte Räume) +await client.joinRoom( + '!roomId:remoteserver.org', + serverName: ['remoteserver.org', 'matrix.org'], +); +``` + +--- + +## Raum verlassen + +```dart +// Verlassen (bleibt in Left-State) +final room = client.getRoomById('!roomId:server')!; +await room.leave(); + +// Vergessen (entfernt aus Raumliste) +await room.forget(); + +// Verlassen + Vergessen +await room.leave(); +await room.forget(); +``` + +--- + +## Raumliste + +```dart +// Alle Räume (joined + invited) +final allRooms = client.rooms; + +// Nur beigetretene +final joinedRooms = client.rooms + .where((r) => r.membership == Membership.join) + .toList(); + +// Nur Einladungen +final invites = client.rooms + .where((r) => r.membership == Membership.invite) + .toList(); + +// Sortiert nach letzter Aktivität +final sorted = [...client.rooms] + ..sort((a, b) => (b.lastEvent?.originServerTs ?? DateTime(0)) + .compareTo(a.lastEvent?.originServerTs ?? DateTime(0))); + +// Raum per ID +final room = client.getRoomById('!roomId:server'); + +// Raum per Alias auflösen +final resolved = await client.getRoomIdByAlias('#alias:server'); +// resolved.roomId + +// Ungelesene Nachrichten +final unread = client.rooms.fold( + 0, (sum, r) => sum + r.notificationCount); +``` + +--- + +## Raum-Metadaten lesen + +```dart +final room = client.getRoomById('!roomId:server')!; + +// Basis-Info +print(room.id); // !roomId:server +print(room.name); // Anzeigename (aufgelöst) +print(room.topic); // Thema +print(room.avatar); // mxc://… URI +print(room.canonicalAlias); // #alias:server +print(room.membership); // Membership.join/invite/leave/ban +print(room.isDirectChat); // DM? +print(room.directChatMatrixID); // Gegenüber-MXID bei DM +print(room.isEncrypted); // E2EE aktiv? +print(room.joinRules); // JoinRules.public/invite/knock/restricted +print(room.guestAccess); // GuestAccess.forbidden/canJoin +print(room.historyVisibility); // HistoryVisibility.* +print(room.notificationCount); // Ungelesene Notifications +print(room.highlightCount); // Mentions +print(room.summary.mJoinedMemberCount); // Mitgliederzahl + +// Letztes Event +final last = room.lastEvent; +print(last?.body); +print(last?.originServerTs); + +// Display-Name (lokalisiert, wie FluffyChat) +final displayName = room.getLocalizedDisplayname(); +``` + +--- + +## Raum-Einstellungen ändern + +```dart +final room = client.getRoomById('!roomId:server')!; + +// Name +await room.setName('Neuer Name'); + +// Thema +await room.setDescription('Neues Thema'); + +// Avatar (mxc:// URI nach Upload) +final uri = await client.uploadContent(imageBytes, filename: 'avatar.png'); +await room.setAvatar(uri); + +// Alias setzen / entfernen +await client.setRoomAlias('#neu:example.org', room.id); +await client.deleteRoomAlias('#alt:example.org'); +await room.setCanonicalAlias('#neu:example.org'); // Kanonischen Alias setzen + +// Join-Rules +await room.setJoinRules(JoinRules.invite); // private +await room.setJoinRules(JoinRules.public); // öffentlich +await room.setJoinRules(JoinRules.knock); // Beitrittsanfragen +await room.setJoinRules( + JoinRules.restricted, + allow: [SpaceRoomJoinRulesAllowInner( + roomId: '!spaceId:server', + type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership, + )], +); + +// History-Visibility +await room.setHistoryVisibility(HistoryVisibility.shared); +// .shared | .invited | .joined | .worldReadable + +// Guest Access +await room.setGuestAccess(GuestAccess.canJoin); +await room.setGuestAccess(GuestAccess.forbidden); + +// E2EE aktivieren (nicht mehr deaktivierbar!) +await room.enableEncryption(); + +// Generischer State-Event senden +await room.client.sendState( + room.id, + 'm.room.custom_event', + {'key': 'value'}, + stateKey: '', +); +``` + +--- + +## Mitglieder + +```dart +final room = client.getRoomById('!roomId:server')!; + +// Alle Mitglieder (gecacht) +final members = room.getParticipants(); + +// Vollständige Mitgliederliste vom Server laden +final allMembers = await room.requestParticipants(); + +// Einzelnen User holen +final user = room.unsafeGetUserFromMemoryOrFallback('@alice:server'); +print(user.displayName); +print(user.avatarUrl); +print(user.powerLevel); +print(user.membership); // Membership.join/invite/leave/ban/knock + +// Aktuell tippende User +final typingUsers = room.typingUsers; // List + +// Power Level eines Users +final pl = room.getPowerLevelByUserId('@alice:server'); + +// Eigener Power Level +final myPl = room.ownPowerLevel; +``` + +--- + +## Einladen, Kick & Ban + +```dart +final room = client.getRoomById('!roomId:server')!; + +// Einladen +await room.invite('@alice:server'); + +// Kick (entfernt, kann wieder beitreten) +await room.kick('@alice:server', reason: 'Regeln verletzt'); + +// Ban (dauerhaft gesperrt) +await room.ban('@alice:server', reason: 'Spam'); + +// Unban +await room.unban('@alice:server'); + +// Eigene Berechtigungen prüfen +room.canBan; // bool +room.canKick; // bool +room.canInvite; // bool +room.canSendEvent(EventTypes.Message); // bool +``` + +--- + +## Power Levels + +```dart +final room = client.getRoomById('!roomId:server')!; + +// Power Level eines Users setzen +await room.setPower('@alice:server', 50); // 0=User, 50=Mod, 100=Admin + +// Power Level Content auslesen +final plContent = room.getState(EventTypes.RoomPowerLevels) + ?.content + .tryGet('users'); + +// Vollständig setzen +await room.client.sendState( + room.id, + EventTypes.RoomPowerLevels, + { + 'ban': 50, + 'events': { + 'm.room.name': 50, + 'm.room.power_levels': 100, + }, + 'events_default': 0, + 'invite': 50, + 'kick': 50, + 'redact': 50, + 'state_default': 50, + 'users': { + '@alice:server': 100, + '@mod:server': 50, + }, + 'users_default': 0, + }, + stateKey: '', +); + +// Berechtigungen prüfen +room.canSendDefaultMessages; // Nachrichten senden +room.canChangeStateEvent(EventTypes.RoomName); // State-Events ändern +``` + +--- + +## Öffentliches Raumverzeichnis + +```dart +// Öffentliche Räume suchen +final result = await client.queryPublicRooms( + limit: 20, + filter: PublicRoomQueryFilter(genericSearchTerm: 'flutter'), + server: 'matrix.org', // optionaler anderer Server +); + +for (final room in result.chunk) { + print(room.name); + print(room.roomId); + print(room.numJoinedMembers); + print(room.worldReadable); +} + +// Nächste Seite +final next = await client.queryPublicRooms( + since: result.nextBatch, + limit: 20, +); +``` + +--- + +## Spaces + +```dart +// Space erstellen +final spaceId = await client.createRoom( + name: 'Mein Space', + creationContent: {'type': 'm.space'}, // markiert als Space + visibility: Visibility.private, +); + +// Kind-Raum zu Space hinzufügen +final space = client.getRoomById(spaceId)!; +await space.client.sendState( + spaceId, + EventTypes.SpaceChild, + { + 'via': ['example.org'], + 'suggested': true, + 'auto_join': false, + }, + stateKey: '!childRoomId:server', +); + +// Space-Kinder auflisten (vom Server) +final children = await client.getSpaceHierarchy( + spaceId, + maxDepth: 2, + suggestedOnly: false, +); +for (final room in children.rooms) { + print(room.name); + print(room.roomType); // 'm.space' oder null + print(room.childrenState); // Sub-Spaces +} + +// Kind-Raum entfernen +await space.client.sendState( + spaceId, + EventTypes.SpaceChild, + {}, // leeres Content → entfernt + stateKey: '!childRoomId:server', +); + +// Space-Parent setzen (vom Kind-Raum aus) +await client.sendState( + '!childRoomId:server', + EventTypes.SpaceParent, + { + 'via': ['example.org'], + 'canonical': true, + }, + stateKey: spaceId, +); + +// Alle Spaces des Users +final spaces = client.rooms.where((r) => + r.membership == Membership.join && + r.getState('m.room.create')?.content['type'] == 'm.space'); +``` + +--- + +## Room Upgrades + +```dart +// Raum auf neuere Version upgraden (erstellt Nachfolger-Raum) +final newRoomId = await client.upgradeRoom( + '!alterRaumId:server', + newVersion: '11', // Aktuelle empfohlene Version +); +// Alle Mitglieder werden automatisch in den neuen Raum eingeladen +// tombstone-Event wird im alten Raum gesetzt + +// Tombstone lesen +final tombstone = room.getState(EventTypes.RoomTombstone); +final replacement = tombstone?.content['replacement_room'] as String?; +``` diff --git a/docs/matrix-sdk/03-messages.md b/docs/matrix-sdk/03-messages.md new file mode 100644 index 0000000..c10eb2c --- /dev/null +++ b/docs/matrix-sdk/03-messages.md @@ -0,0 +1,463 @@ +# 03 — Nachrichten & Events + +--- + +## Inhaltsverzeichnis +- [Textnachricht senden](#textnachricht-senden) +- [HTML / Markdown senden](#html--markdown-senden) +- [Antworten (Reply)](#antworten-reply) +- [Bearbeiten (Edit)](#bearbeiten-edit) +- [Reaktionen](#reaktionen) +- [Löschen (Redact)](#löschen-redact) +- [Threads](#threads) +- [Polls](#polls) +- [Standort](#standort) +- [Timeline lesen](#timeline-lesen) +- [Event-Typen Referenz](#event-typen-referenz) +- [Typing-Indikator](#typing-indikator) +- [Read Markers / Lesebestätigung](#read-markers--lesebestätigung) +- [Pinned Messages](#pinned-messages) +- [Fehlgeschlagene Events erneut senden](#fehlgeschlagene-events-erneut-senden) + +--- + +## Textnachricht senden + +```dart +final room = client.getRoomById('!roomId:server')!; + +// Einfacher Text +final eventId = await room.sendTextEvent('Hallo Welt!'); + +// Gibt ein temporäres Event zurück (für optimistische UI) +// Das Event bekommt Status EventStatus.sending → .sent → .synced +``` + +--- + +## HTML / Markdown senden + +```dart +// Mit HTML-Formatierung +await room.sendTextEvent( + 'Fett und kursiv', // Fallback-Text (Plain) + htmlBody: 'Fett und kursiv', + parseMarkdown: false, // Kein automatisches Markdown-Parsing +); + +// Automatisches Markdown-Parsing (SDK parst ** → ) +await room.sendTextEvent( + '**Fett** und *kursiv*', + parseMarkdown: true, // Standard: true +); +``` + +--- + +## Antworten (Reply) + +```dart +// Event, auf das geantwortet wird +final replyTo = timeline.events.first; // Event-Objekt + +await room.sendTextEvent( + 'Das sehe ich auch so!', + inReplyTo: replyTo, +); + +// Prüfen ob ein Event eine Antwort ist +final isReply = event.isReply; // bool +final repliedId = event.inReplyTo; // eventId als String? + +// Das referenzierte Event laden +final original = await room.getEventById(repliedId!); +``` + +--- + +## Bearbeiten (Edit) + +```dart +// Nachricht bearbeiten +await room.sendTextEvent( + 'Korrigierter Text', + editEventId: altesEvent.eventId, // Referenz auf Original + htmlBody: 'Alt Korrigiert', // optional +); + +// Prüfen ob ein Event bearbeitet wurde +final isEdit = event.isEdit; // bool + +// Gerendertes Event (mit letzter Bearbeitung) holen +final displayed = event.getDisplayEvent(); // gibt die letzte Version zurück +print(displayed.body); // aktuelle Nachricht + +// Alle Bearbeitungen eines Events +final edits = timeline.events + .where((e) => e.isEdit && e.relationshipEventId == originalEvent.eventId) + .toList(); +``` + +--- + +## Reaktionen + +```dart +// Reaktion senden (Emoji oder Text) +await room.sendReaction(event.eventId, '👍'); +await room.sendReaction(event.eventId, '❤️'); + +// Alle Reaktionen eines Events lesen +final reactions = event.aggregatedEvents( + timeline, + RelationshipTypes.reaction, +); +// Gruppiert nach Emoji-Key: +// Map> + +// Eigene Reaktion entfernen (= Redact) +final myReaction = reactions['👍'] + ?.firstWhere((e) => e.senderId == client.userID); +if (myReaction != null) { + await myReaction.redactEvent(reason: null); +} +``` + +--- + +## Löschen (Redact) + +```dart +// Event löschen +await room.redactEvent( + event.eventId, + reason: 'Unpassender Inhalt', // optional +); + +// Über das Event-Objekt direkt +await event.redactEvent(reason: 'Spam'); + +// Prüfen ob redacted +final redacted = event.isRedacted; // bool +``` + +--- + +## Threads + +```dart +// Thread-Antwort senden +await room.sendEvent( + { + 'm.relates_to': { + 'rel_type': 'm.thread', + 'event_id': rootEvent.eventId, + 'is_falling_back': true, // true wenn kein echter Thread-Client + 'm.in_reply_to': { + 'event_id': rootEvent.eventId, + }, + }, + 'msgtype': 'm.text', + 'body': 'Antwort im Thread', + }, + EventTypes.Message, +); + +// Thread-Events eines Root-Events laden +final threadEvents = timeline.events + .where((e) => + e.relationshipType == RelationshipTypes.thread && + e.relationshipEventId == rootEvent.eventId) + .toList(); +``` + +--- + +## Polls + +```dart +// Poll erstellen (MSC3381) +await room.sendEvent( + { + 'org.matrix.msc3381.poll.start': { + 'question': { + 'msgtype': 'm.text', + 'body': 'Was ist dein Lieblingsessen?', + }, + 'kind': 'org.matrix.msc3381.poll.disclosed', // oder .undisclosed + 'max_selections': 1, + 'answers': [ + {'id': '1', 'org.matrix.msc3381.poll.answer': {'msgtype': 'm.text', 'body': 'Pizza'}}, + {'id': '2', 'org.matrix.msc3381.poll.answer': {'msgtype': 'm.text', 'body': 'Pasta'}}, + {'id': '3', 'org.matrix.msc3381.poll.answer': {'msgtype': 'm.text', 'body': 'Salat'}}, + ], + }, + 'msgtype': 'm.text', + 'body': 'Was ist dein Lieblingsessen? (Poll)', + }, + 'org.matrix.msc3381.poll.start', +); + +// Abstimmen +await room.sendEvent( + { + 'm.relates_to': { + 'rel_type': 'm.reference', + 'event_id': pollEvent.eventId, + }, + 'org.matrix.msc3381.poll.response': { + 'answers': ['1'], // Ausgewählte IDs + }, + 'msgtype': 'm.text', + 'body': 'Meine Antwort: Pizza', + }, + 'org.matrix.msc3381.poll.response', +); + +// Poll beenden +await room.sendEvent( + { + 'm.relates_to': { + 'rel_type': 'm.reference', + 'event_id': pollEvent.eventId, + }, + 'msgtype': 'm.text', + 'body': 'Poll beendet.', + }, + 'org.matrix.msc3381.poll.end', +); +``` + +--- + +## Standort + +```dart +// Standort senden +await room.sendEvent( + { + 'msgtype': 'm.location', + 'body': 'Mein Standort: geo:52.5200,13.4050', + 'geo_uri': 'geo:52.5200,13.4050', + 'org.matrix.msc3488.location': { + 'uri': 'geo:52.5200,13.4050', + 'description': 'Berlin', + }, + }, + EventTypes.Message, +); +``` + +--- + +## Timeline lesen + +```dart +final room = client.getRoomById('!roomId:server')!; + +// Timeline erstellen (Callbacks werden bei Änderungen gerufen) +Timeline? timeline; +timeline = await room.getTimeline( + onUpdate: () { + // Vollständiges UI-Rebuild + setState(() {}); + }, + onChange: (int index) { + // Einzelnes Event geändert (z.B. Reaktion, Edit, Status) + setState(() {}); + }, + onInsert: (int index) { + // Neues Event eingefügt + setState(() {}); + }, + onRemove: (int index) { + // Event entfernt (Redaction) + setState(() {}); + }, +); + +// WICHTIG: Immer aufräumen! +@override +void dispose() { + timeline?.cancelSubscriptions(); + super.dispose(); +} + +// Events abrufen (neueste zuerst, Index 0 = aktuellstes) +final events = timeline.events; + +// Ältere Nachrichten nachladen (z.B. beim Scrollen nach oben) +await timeline.requestHistory(count: 30); +// Gibt true zurück wenn es noch mehr gibt +final hasMore = timeline.canRequestHistory; + +// Bestimmtes Event aus Timeline oder Server laden +final event = await room.getEventById('$eventId'); + +// Neuere Events nachladen (nach Gap) +await timeline.requestFuture(count: 20); +``` + +--- + +## Event-Typen Referenz + +```dart +// Nachrichten-Typen (msgtype) +'m.text' // Textnachricht +'m.emote' // /me Aktion +'m.notice' // Bot-Nachricht (dezenter) +'m.image' // Bild +'m.video' // Video +'m.audio' // Audio/Sprachnachricht +'m.file' // Datei +'m.location' // Standort +'m.sticker' // Sticker (eigener Event-Typ: m.sticker) + +// Event-Typen (type) +EventTypes.Message // m.room.message +EventTypes.Sticker // m.sticker +EventTypes.Reaction // m.reaction +EventTypes.RoomRedaction // m.room.redaction +EventTypes.Encrypted // m.room.encrypted +EventTypes.RoomName // m.room.name +EventTypes.RoomTopic // m.room.topic +EventTypes.RoomAvatar // m.room.avatar +EventTypes.RoomMember // m.room.member +EventTypes.RoomPowerLevels // m.room.power_levels +EventTypes.RoomCanonicalAlias // m.room.canonical_alias +EventTypes.RoomJoinRules // m.room.join_rules +EventTypes.RoomGuestAccess // m.room.guest_access +EventTypes.HistoryVisibility // m.room.history_visibility +EventTypes.RoomCreate // m.room.create +EventTypes.RoomTombstone // m.room.tombstone +EventTypes.Encryption // m.room.encryption +EventTypes.SpaceChild // m.space.child +EventTypes.SpaceParent // m.space.parent +EventTypes.CallInvite // m.call.invite +EventTypes.CallAnswer // m.call.answer +EventTypes.CallHangup // m.call.hangup + +// Event-Status prüfen +event.status == EventStatus.sending // Wird gesendet (optimistisch) +event.status == EventStatus.sent // Vom Server bestätigt +event.status == EventStatus.synced // Im eigenen Sync empfangen +event.status == EventStatus.error // Fehlgeschlagen +event.status == EventStatus.removed // Gelöscht + +// Nützliche Event-Properties +event.type // String: Event-Typ +event.eventId // String: ID +event.senderId // String: MXID des Senders +event.originServerTs // DateTime: Sendezeitpunkt +event.body // String: Nachrichtentext (entschlüsselt) +event.messageType // String?: msgtype +event.formattedBody // String?: HTML-Inhalt +event.isReply // bool +event.isEdit // bool +event.isReacted // bool +event.isRedacted // bool +event.isEncrypted // bool +event.isBadEncrypted // bool: Entschlüsselung fehlgeschlagen +event.isState // bool: State-Event? +event.relationshipEventId // String?: ID des referenzierten Events +event.relationshipType // String?: Beziehungstyp (m.replace, m.thread, etc.) + +// Sender-User holen +final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId); +print(sender.displayName); +print(sender.avatarUrl); +``` + +--- + +## Typing-Indikator + +```dart +// Schreiben anzeigen +await room.setTyping(true, timeout: 30000); // 30 Sekunden + +// Schreiben beenden +await room.setTyping(false); + +// Tippende User im Raum beobachten +final typing = room.typingUsers; // List +// Automatisch aktuell durch Sync +``` + +--- + +## Read Markers / Lesebestätigung + +```dart +// Gelesenes Event markieren +await room.setReadMarker( + event.eventId, + mRead: event.eventId, // private receipt (unsichtbar für andere) + // public receipt weglassen für private-only +); + +// Öffentliche + private Lesebestätigung +await room.setReadMarker( + event.eventId, + mRead: event.eventId, +); + +// Letztes gelesenes Event +final lastRead = room.lastReadEventId; + +// Ungelesen-Badge +final count = room.notificationCount; // Notifications +final hl = room.highlightCount; // Mentions/Highlights +``` + +--- + +## Pinned Messages + +```dart +// Events anpinnen +final currentPinned = (room.getState(EventTypes.RoomPinnedEvents) + ?.content['pinned'] as List?) + ?.cast() ?? []; + +await room.client.sendState( + room.id, + EventTypes.RoomPinnedEvents, + {'pinned': [...currentPinned, eventId]}, + stateKey: '', +); + +// Angepinnte Events lesen +final pinned = (room.getState(EventTypes.RoomPinnedEvents) + ?.content['pinned'] as List?) + ?.cast() ?? []; + +// Events laden +final pinnedEvents = await Future.wait( + pinned.map((id) => room.getEventById(id)), +); +``` + +--- + +## Fehlgeschlagene Events erneut senden + +```dart +// Events mit Status error finden +final failed = timeline.events + .where((e) => e.status == EventStatus.error) + .toList(); + +// Erneut senden +for (final event in failed) { + await event.sendAgain(); +} + +// Oder aus dem Timeline-Controller: +await room.sendEvent( + event.content, + event.type, + txnId: event.eventId, // Gleiche txnId → überschreibt fehlgeschlagenes +); +``` diff --git a/docs/matrix-sdk/04-dm.md b/docs/matrix-sdk/04-dm.md new file mode 100644 index 0000000..6c10b14 --- /dev/null +++ b/docs/matrix-sdk/04-dm.md @@ -0,0 +1,221 @@ +# 04 — Direct Messages (DMs) + +--- + +## Inhaltsverzeichnis +- [DM starten oder finden](#dm-starten-oder-finden) +- [DM-Raum identifizieren](#dm-raum-identifizieren) +- [DM-Liste](#dm-liste) +- [Gruppen-DM](#gruppen-dm) +- [DM-Raum schließen / vergessen](#dm-raum-schließen--vergessen) +- [User-Profil für DM laden](#user-profil-für-dm-laden) +- [Verschlüsselung in DMs](#verschlüsselung-in-dms) + +--- + +## DM starten oder finden + +```dart +// Existierenden DM finden oder neuen erstellen +// Gibt immer einen joined Room zurück +final room = await client.startDirectChat('@alice:server'); +print(room.id); // !roomId:server + +// Mit expliziten Optionen +final room = await client.startDirectChat( + '@alice:server', + // enableEncryption: true, // Standard: true +); + +// Navigieren zum DM-Raum +// (z.B. in Pyramid:) +ref.read(activeRoomIdProvider.notifier).state = room.id; +``` + +--- + +## DM-Raum identifizieren + +```dart +final room = client.getRoomById('!roomId:server')!; + +// Ist es ein DM? +final isDm = room.isDirectChat; // bool + +// Mit wem? +final partner = room.directChatMatrixID; // '@alice:server' oder null + +// DM zu einem bestimmten User finden +final existingDm = client.getDirectChatFromUserId('@alice:server'); +// Gibt Room? zurück (null wenn kein DM vorhanden) + +// Alle DMs des Users (Account-Data) +final dmMap = client.directChats; +// Map>: { '@alice:server': ['!roomId:server'] } +``` + +--- + +## DM-Liste + +```dart +// Alle DM-Räume (joined) +final dms = client.rooms.where((r) => + r.isDirectChat && + r.membership == Membership.join, +).toList(); + +// Sortiert nach letzter Aktivität +dms.sort((a, b) => + (b.lastEvent?.originServerTs ?? DateTime(0)) + .compareTo(a.lastEvent?.originServerTs ?? DateTime(0))); + +// DMs mit ungelesenen Nachrichten +final unreadDms = dms.where((r) => r.notificationCount > 0).toList(); + +// Display-Info für DM-Liste +for (final dm in dms) { + final partner = client.getDirectChatFromUserId(dm.directChatMatrixID!); + final user = dm.unsafeGetUserFromMemoryOrFallback(dm.directChatMatrixID!); + print(user.displayName ?? dm.directChatMatrixID); + print(user.avatarUrl); + print(dm.lastEvent?.body); // Vorschautext + print(dm.lastEvent?.originServerTs); // Zeitstempel + print(dm.notificationCount); // Ungelesen +} +``` + +--- + +## Gruppen-DM + +```dart +// Gruppen-DM mit mehreren Personen erstellen +final roomId = await client.createRoom( + name: 'Gruppenunterhaltung', + invite: ['@bob:server', '@charlie:server'], + isDirect: true, // Als DM markieren + preset: CreateRoomPreset.trustedPrivateChat, + enableEncryption: true, +); + +// Oder mit startDirectChat (für Einzel-DMs) und manuell Teilnehmer hinzufügen +final room = await client.startDirectChat('@bob:server'); +await room.invite('@charlie:server'); + +// Gruppe als DM im Account-Data kennzeichnen +// (passiert automatisch bei isDirect: true) +``` + +--- + +## DM-Raum schließen / vergessen + +```dart +final room = client.getRoomById('!roomId:server')!; + +// Verlassen (Gegenüber kann weiter chatten) +await room.leave(); + +// Aus Raumliste entfernen +await room.forget(); + +// DM aus Account-Daten entfernen +final dmMap = Map.from(client.directChats); +dmMap.remove('@alice:server'); +// oder nur den spezifischen Raum entfernen: +final rooms = List.from(dmMap['@alice:server'] ?? []); +rooms.remove(room.id); +if (rooms.isEmpty) { + dmMap.remove('@alice:server'); +} else { + dmMap['@alice:server'] = rooms; +} +await client.setAccountData( + client.userID!, + 'm.direct', + dmMap, +); +``` + +--- + +## User-Profil für DM laden + +```dart +// Profil des DM-Partners laden +final partnerMxid = room.directChatMatrixID!; + +// Aus Room-Membership (gecacht, schnell) +final user = room.unsafeGetUserFromMemoryOrFallback(partnerMxid); +print(user.displayName); +print(user.avatarUrl); + +// Frisch vom Server (genauer, aber langsamer) +final profile = await client.getProfileFromUserId(partnerMxid); +print(profile.displayname); +print(profile.avatarUrl); + +// Avatar-URL in Widget verwenden +// (mxc:// URI → HTTP-URL via SDK) +final httpUrl = user.avatarUrl?.getThumbnail( + client, + width: 56, + height: 56, + method: ThumbnailMethod.crop, +); +``` + +--- + +## Verschlüsselung in DMs + +```dart +// E2EE-Status prüfen +final isEncrypted = room.isEncrypted; + +// E2EE aktivieren (einmalig, nicht mehr rückgängig!) +if (!room.isEncrypted) { + await room.enableEncryption(); +} + +// Geräte des Partners verifizieren +final partnerDevices = await client.getUserDeviceKeys('@alice:server'); +for (final device in partnerDevices?.deviceKeys.values ?? []) { + print('${device.deviceId}: verified=${device.verified}'); +} + +// Fehlende Schlüssel für Entschlüsselung anfordern +await client.requestMissingSessionKeys(); + +// Schlechte Verschlüsselung erkennen +final badEvents = timeline.events + .where((e) => e.isBadEncrypted) + .toList(); +``` + +--- + +## Einladungen (DM-Invite-Ansicht) + +```dart +// Eingehende DM-Einladungen +final invites = client.rooms.where((r) => + r.membership == Membership.invite && + r.isDirectChat).toList(); + +// Einladung anzeigen +for (final invite in invites) { + final senderId = invite.getState(EventTypes.RoomMember, client.userID!) + ?.senderId; // Wer hat eingeladen? + final sender = await client.getProfileFromUserId(senderId ?? ''); + print('Einladung von ${sender.displayname}'); + + // Annehmen + await invite.join(); + + // Ablehnen + await invite.leave(); + await invite.forget(); +} +``` diff --git a/docs/matrix-sdk/05-media.md b/docs/matrix-sdk/05-media.md new file mode 100644 index 0000000..9f1750c --- /dev/null +++ b/docs/matrix-sdk/05-media.md @@ -0,0 +1,355 @@ +# 05 — Medien & Dateien + +--- + +## Inhaltsverzeichnis +- [Datei hochladen](#datei-hochladen) +- [Bild senden](#bild-senden) +- [Video senden](#video-senden) +- [Audio / Sprachnachricht senden](#audio--sprachnachricht-senden) +- [Datei senden](#datei-senden) +- [Sticker senden](#sticker-senden) +- [Medien herunterladen & entschlüsseln](#medien-herunterladen--entschlüsseln) +- [Thumbnails & Vorschaubilder](#thumbnails--vorschaubilder) +- [MXC-URL zu HTTP-URL](#mxc-url-zu-http-url) +- [Verschlüsselte Medien](#verschlüsselte-medien) +- [Avatar hochladen](#avatar-hochladen) +- [MatrixFile-Objekte](#matrixfile-objekte) + +--- + +## Datei hochladen + +```dart +// Bytes hochladen → mxc:// URI zurück +final mxcUri = await client.uploadContent( + fileBytes, // Uint8List + filename: 'datei.pdf', + contentType: 'application/pdf', // optional, SDK erkennt es +); +print(mxcUri); // mxc://example.org/AbCdEf... +``` + +--- + +## Bild senden + +```dart +import 'package:matrix/matrix.dart'; + +// MatrixImageFile erstellen +final imageFile = MatrixImageFile( + bytes: imageBytes, // Uint8List + name: 'foto.jpg', + mimeType: 'image/jpeg', + width: 1920, // optional, für Info + height: 1080, +); + +// Mit Thumbnail +final thumbnail = MatrixImageFile( + bytes: thumbnailBytes, + name: 'foto_thumb.jpg', + mimeType: 'image/jpeg', + width: 320, + height: 180, +); + +// Senden (lädt automatisch hoch + sendet m.image Event) +await room.sendFileEvent( + imageFile, + thumbnail: thumbnail, // optional +); + +// Alternativ mit sendImageEvent +await room.sendImageEvent( + imageFile, + thumbnail: thumbnail, +); +``` + +--- + +## Video senden + +```dart +final videoFile = MatrixVideoFile( + bytes: videoBytes, + name: 'video.mp4', + mimeType: 'video/mp4', + duration: 45000, // Millisekunden + width: 1920, + height: 1080, +); + +final thumbFile = MatrixImageFile( + bytes: thumbnailBytes, + name: 'thumb.jpg', + mimeType: 'image/jpeg', + width: 320, + height: 180, +); + +await room.sendVideoEvent( + videoFile, + thumbnail: thumbFile, +); +``` + +--- + +## Audio / Sprachnachricht senden + +```dart +// Audio-Datei +final audioFile = MatrixAudioFile( + bytes: audioBytes, + name: 'aufnahme.ogg', + mimeType: 'audio/ogg', + duration: 12000, // Millisekunden +); + +await room.sendAudioEvent(audioFile); + +// Sprachnachricht (Voice Message MSC3245) +await room.sendFileEvent( + audioFile, + extraContent: { + 'org.matrix.msc3245.voice': {}, // markiert als Sprachnachricht + 'org.matrix.msc1767.audio': { + 'duration': 12000, + 'waveform': [0, 50, 100, 75, ...], // Amplituden (0-1024) + }, + }, +); +``` + +--- + +## Datei senden + +```dart +final file = MatrixFile( + bytes: fileBytes, + name: 'dokument.pdf', + mimeType: 'application/pdf', +); + +await room.sendFileEvent(file); + +// Mit Extra-Metadaten +await room.sendFileEvent( + file, + extraContent: { + 'org.matrix.msc3554.caption': { + 'msgtype': 'm.text', + 'body': 'Hier das Dokument', + }, + }, +); +``` + +--- + +## Sticker senden + +```dart +// Sticker sind ein eigener Event-Typ (m.sticker) +await room.sendEvent( + { + 'body': 'Ein Sticker', + 'url': 'mxc://example.org/stickerId', + 'info': { + 'mimetype': 'image/png', + 'w': 256, + 'h': 256, + }, + }, + EventTypes.Sticker, // 'm.sticker' +); +``` + +--- + +## Medien herunterladen & entschlüsseln + +```dart +// Aus Event-Attachment herunterladen (+ automatisch entschlüsseln bei E2EE) +final data = await event.downloadAndDecryptAttachment( + getThumbnail: false, // true für Thumbnail statt Original +); +// data ist MatrixFile mit .bytes: Uint8List + +// Direkte MXC-URL herunterladen +final bytes = await client.database.getFile(mxcUri); +// oder über HTTP: +final httpUri = mxcUri.getDownloadUri(client); +// → https://homeserver/_matrix/media/v3/download/server/mediaId +``` + +--- + +## Thumbnails & Vorschaubilder + +```dart +// Thumbnail-URL für Widget +final thumbnailUri = event.thumbnailUrl; // mxc:// URI + +// HTTP-URL für Thumbnail (mit Größe) +final httpUrl = thumbnailUri?.getThumbnail( + client, + width: 800, + height: 600, + method: ThumbnailMethod.scale, // oder .crop +); + +// Avatar-Thumbnail +final avatarUrl = user.avatarUrl?.getThumbnail( + client, + width: 56, + height: 56, + method: ThumbnailMethod.crop, +); + +// Im Flutter Widget (CachedNetworkImage o.ä.): +Image.network(httpUrl.toString()); +``` + +--- + +## MXC-URL zu HTTP-URL + +```dart +// Download-URL +final downloadUrl = Uri.parse(mxcUriString).getDownloadUri(client); +// → https://homeserver/_matrix/media/v3/download/server/mediaId + +// Thumbnail-URL +final thumbUrl = Uri.parse(mxcUriString).getThumbnail( + client, + width: 300, + height: 300, + method: ThumbnailMethod.crop, +); + +// Direkt als String +final urlString = downloadUrl.toString(); +``` + +--- + +## Verschlüsselte Medien + +```dart +// In verschlüsselten Räumen werden Medien automatisch verschlüsselt. +// Das SDK übernimmt Ver- und Entschlüsselung transparent. + +// Verschlüsselter Download (automatisch): +final file = await event.downloadAndDecryptAttachment(); +// file.bytes enthält die entschlüsselten Rohdaten + +// Manuell ver-/entschlüsseln (selten nötig): +// Nutze EncryptedFile aus dem matrix-package +final encryptedFile = EncryptedFile( + url: 'mxc://...', + key: {/* JWK */}, + iv: 'base64...', + hashes: {'sha256': 'base64...'}, + v: 'v2', +); +``` + +--- + +## Avatar hochladen + +```dart +// Eigener Avatar +final avatarBytes = await File('avatar.png').readAsBytes(); +final mxcUri = await client.uploadContent( + avatarBytes, + filename: 'avatar.png', + contentType: 'image/png', +); +await client.setAvatarUrl(client.userID!, mxcUri); + +// Raum-Avatar +final roomAvatarMxc = await client.uploadContent(imageBytes, filename: 'room.png'); +await room.setAvatar(roomAvatarMxc); +``` + +--- + +## MatrixFile-Objekte + +```dart +// Basis-Klasse +MatrixFile({ + required Uint8List bytes, + required String name, + String? mimeType, +}) + +// Spezialisierte Unterklassen: +MatrixImageFile({ + required Uint8List bytes, + required String name, + String? mimeType, // z.B. 'image/jpeg' + int? width, + int? height, + bool? animated, // für GIFs +}) + +MatrixVideoFile({ + required Uint8List bytes, + required String name, + String? mimeType, // 'video/mp4' + int? duration, // ms + int? width, + int? height, + MatrixImageFile? thumbnail, +}) + +MatrixAudioFile({ + required Uint8List bytes, + required String name, + String? mimeType, // 'audio/ogg' + int? duration, // ms +}) + +// Eigenschaften +file.bytes // Uint8List +file.name // Dateiname +file.mimeType // MIME-Typ +file.sizeString // '1.4 MB' (formatiert) +file.size // Bytes als int +``` + +--- + +## Medien-Events lesen + +```dart +// Attachment-Info aus Event +if (event.messageType == 'm.image') { + final url = event.attachmentMxcUrl; // mxc:// URI + final mime = event.attachmentMimetype; // 'image/jpeg' + final size = event.attachmentSize; // Bytes + final width = event.infoMap['w'] as int?; + final height = event.infoMap['h'] as int?; +} + +if (event.messageType == 'm.video') { + final duration = event.infoMap['duration'] as int?; // ms +} + +if (event.messageType == 'm.audio') { + final duration = event.infoMap['duration'] as int?; + final waveform = event.content + .tryGetMap('org.matrix.msc1767.audio') + ?.tryGetList('waveform'); +} + +// Datei-Größe als lesbarer String +print(event.sizeString); // '2.3 MB' +``` diff --git a/docs/matrix-sdk/06-encryption.md b/docs/matrix-sdk/06-encryption.md new file mode 100644 index 0000000..a45004c --- /dev/null +++ b/docs/matrix-sdk/06-encryption.md @@ -0,0 +1,313 @@ +# 06 — E2EE Verschlüsselung + +--- + +## Inhaltsverzeichnis +- [Setup & Initialisierung](#setup--initialisierung) +- [Verschlüsselung aktivieren](#verschlüsselung-aktivieren) +- [Entschlüsselung & Fehlerfälle](#entschlüsselung--fehlerfälle) +- [Device-Verifikation (SAS)](#device-verifikation-sas) +- [Cross-Signing](#cross-signing) +- [Key Backup](#key-backup) +- [Fehlende Session Keys anfordern](#fehlende-session-keys-anfordern) +- [Geräte-Keys abfragen](#geräte-keys-abfragen) +- [Trust-Status](#trust-status) + +--- + +## Setup & Initialisierung + +```dart +// flutter_vodozemac muss initialisiert sein BEVOR Client erstellt wird +import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; +await vod.init(); + +// Client mit E2EE-Support +final client = Client( + 'Pyramid', + database: await MatrixSdkDatabase.init('pyramid', database: db), + nativeImplementations: NativeImplementationsIsolate(compute), + // Oder für Hintergrund-Isolaten (kein compute): + // nativeImplementations: NativeImplementationsDummy(), + verificationMethods: { + KeyVerificationMethod.numbers, + KeyVerificationMethod.emoji, + // KeyVerificationMethod.qrCodeScan, // falls QR-Scanner vorhanden + // KeyVerificationMethod.qrCodeShow, + }, +); + +await client.init( + waitForFirstSync: false, + waitUntilLoadCompletedLoaded: false, +); + +// E2EE verfügbar prüfen +print(client.encryptionEnabled); // true wenn Vodozemac/Olm geladen +``` + +--- + +## Verschlüsselung aktivieren + +```dart +// Raum-Verschlüsselung einschalten (PERMANENT, nicht rückgängig!) +if (!room.isEncrypted) { + await room.enableEncryption(); +} + +// Verschlüsselung beim Erstellen aktivieren +final roomId = await client.createRoom( + name: 'Verschlüsselter Raum', + enableEncryption: true, + preset: CreateRoomPreset.privateChat, +); + +// Status prüfen +print(room.isEncrypted); // bool +print(room.encryptionAlgorithm); // 'm.megolm.v1.aes-sha2' +``` + +--- + +## Entschlüsselung & Fehlerfälle + +```dart +// Beim Anzeigen von Events prüfen: +if (event.isBadEncrypted) { + // Anzeigen: "Nachricht kann nicht entschlüsselt werden" + // Mögliche Ursachen: + // - Gerät war nicht in der Raumschlüssel-Verteilung + // - Session fehlt (Megolm Session nicht vorhanden) + // - Falsches Gerät / fehlende Olm-Session +} + +// Entschlüsselungs-Fehler-Typ +if (event.content['can_not_decrypt'] != null) { + // Generischer Fehler +} +if (event.type == EventTypes.Encrypted && event.plaintextContent == null) { + // Noch nicht entschlüsselt +} + +// Event-Body bei Fehler prüfen +final body = event.body; +// Enthält "** Unable to decrypt: ..." bei Fehlern + +// Fehlende Schlüssel anfordern (von anderen Geräten) +await client.requestMissingSessionKeys(); +``` + +--- + +## Device-Verifikation (SAS) + +```dart +// ── Verifikation starten (ich verifiziere ein anderes Gerät) ────────────── + +// Verifikations-Anfrage senden +final request = await client.userDeviceKeys[otherUserId]! + .startVerification(); +// request.onUpdate: Stream mit Zustandsänderungen + +// ── Verifikation empfangen (anderes Gerät will mich verifizieren) ───────── + +// Eingehende Anfragen beobachten +client.onKeyVerificationRequest.stream.listen((request) async { + // Anfrage annehmen + await request.acceptVerification(); +}); + +// ── SAS-Flow ───────────────────────────────────────────────────────────── + +// Auf SAS-Ready warten und Emojis/Zahlen anzeigen +request.onUpdate.stream.listen((_) async { + if (request.state == KeyVerificationState.showSas) { + // Emojis anzeigen + final emojis = request.sasEmojis; // List + for (final emoji in emojis) { + print('${emoji.emoji} ${emoji.name}'); + } + + // Oder Dezimalzahlen + final decimals = request.sasDecimals; // List (3 Zahlen) + print(decimals); // z.B. [1234, 5678, 9012] + + // Wenn User bestätigt dass sie übereinstimmen: + await request.confirmSas(); + + // Wenn sie nicht übereinstimmen: + // await request.rejectSas(); + } + + if (request.state == KeyVerificationState.done) { + print('Verifikation erfolgreich!'); + } + + if (request.state == KeyVerificationState.error) { + print('Verifikation fehlgeschlagen: ${request.cancelCode}'); + } +}); + +// ── QR-Code Verifikation ───────────────────────────────────────────────── + +// QR-Code zum Anzeigen generieren +final qrData = await request.generateQrCodeData(); +// qrData als QR-Code rendern (z.B. mit qr_flutter-Package) + +// Gescannten QR-Code verarbeiten +await request.scanQrCode(scannedData); + +// Bestätigen dass QR gescannt wurde +await request.confirmQrCodeScanned(); +``` + +--- + +## Cross-Signing + +```dart +// Cross-Signing bootstrappen (erstellt Master-, Self- und User-Signing-Keys) +// Nur einmal pro Account nötig +await client.encryption!.bootstrapCrossSigning( + setupMasterKey: true, + setupSelfSigningKey: true, + setupUserSigningKey: true, +); + +// Cross-Signing-Status +final crossSigning = client.encryption!.crossSigning; +print(crossSigning.enabled); // bool +print(crossSigning.selfSigned); // bool: eigenes Gerät ist self-signed + +// Eigenes Gerät ist verifiziert? +final myDevice = client.userDeviceKeys[client.userID] + ?.deviceKeys[client.deviceID]; +print(myDevice?.verified); // bool + +// Anderen User als vertrauenswürdig markieren +await client.encryption!.crossSigning.signUser(otherUserId); + +// Gerät verifizieren (nach SAS/QR) +await myDevice?.setVerified(true); +``` + +--- + +## Key Backup + +```dart +// ── Key Backup einrichten ───────────────────────────────────────────────── + +// Vollständiges Crypto-Setup (empfohlen): +// Erstellt Secret Storage + Cross-Signing + Key Backup +await client.bootstrapCryptoIdentity( + wipeRecovery: false, // true: bestehende Recovery überschreiben +); + +// Nur Key Backup: +final backup = client.encryption!.keyManager; + +// Recovery-Key generieren und anzeigen +final recoveryKey = await client.encryption!.ssss.generateKey(); +print(recoveryKey); // Langer alphanumerischer Schlüssel → User aufschreiben lassen! + +// ── Key Backup Status ───────────────────────────────────────────────────── + +// Ist Key Backup aktiv? +final keyBackupEnabled = client.encryption!.keyManager.enabled; + +// Alle Sessions hochladen +await client.encryption!.keyManager.uploadInboundGroupSessions(); + +// ── Backup wiederherstellen (Recovery) ─────────────────────────────────── + +// Keys von Server-Backup wiederherstellen +await client.encryption!.keyManager.loadFromResponse( + await client.getRoomKeysBackup(), +); + +// Oder mit Recovery-Key: +await client.encryption!.ssss.unlock(recoveryKey: eingabe); +await client.encryption!.keyManager.restore(); +``` + +--- + +## Fehlende Session Keys anfordern + +```dart +// Schlüssel von anderen Geräten desselben Users anfordern +await client.requestMissingSessionKeys(); + +// Für einen spezifischen Raum +await client.requestMissingSessionKeys( + rooms: [room], +); + +// Einzelne Session anfordern (für ein spezifisches Event) +if (event.isBadEncrypted) { + final sessionId = event.content + .tryGet('session_id'); + final senderKey = event.content + .tryGet('sender_key'); + if (sessionId != null) { + await room.requestKey(sessionId); + } +} +``` + +--- + +## Geräte-Keys abfragen + +```dart +// Alle Geräte eines Users laden +final userDevices = await client.getUserDeviceKeys(userId); +// → Map + +// Geräte eines Raums (alle Mitglieder) +final roomDevices = await room.getUserDeviceKeys(); + +// Eigene Geräte +final myDevices = client.userDeviceKeys[client.userID]?.deviceKeys; + +// Gerät-Details +for (final device in myDevices?.values ?? []) { + print(device.deviceId); + print(device.displayName); + print(device.verified); + print(device.blocked); + print(device.ed25519Key); // Signatur-Schlüssel + print(device.curve25519Key); // Encryption-Schlüssel +} + +// Gerät sperren (Nachrichten werden nicht mehr entschlüsselt gesendet) +await myDevices?['DEVICEID']?.setBlocked(true); +``` + +--- + +## Trust-Status + +```dart +// Trust-Level für Raum berechnen +final encryption = room.client.encryption; +if (encryption != null) { + final trust = await room.calcEncryptionHealthState(); + // trust: .allVerified | .unverifiedDevices | .unknown +} + +// User-Trust +final userTrust = client.userDeviceKeys[userId]?.verified; +// null = unbekannt, true = verifiziert, false = blockiert + +// Nachrichten-Verifikation +if (!event.encryptionHealthState.allVerified) { + // Anzeigen: "Gesendet von unverifiziertem Gerät" +} + +// Eigene Identität +final myIdentity = client.encryption?.crossSigning; +print(myIdentity?.selfSigned); // Eigenes Gerät cross-signed? +``` diff --git a/docs/matrix-sdk/07-push.md b/docs/matrix-sdk/07-push.md new file mode 100644 index 0000000..df5c14e --- /dev/null +++ b/docs/matrix-sdk/07-push.md @@ -0,0 +1,291 @@ +# 07 — Push & Benachrichtigungen + +--- + +## Inhaltsverzeichnis +- [Pusher registrieren (FCM)](#pusher-registrieren-fcm) +- [Pusher abrufen & verwalten](#pusher-abrufen--verwalten) +- [Push Rules](#push-rules) +- [onNotification Stream](#onnotification-stream) +- [Lokale Benachrichtigungen (Android)](#lokale-benachrichtigungen-android) +- [Benachrichtigungen: Pyramid-Architektur](#benachrichtigungen-pyramid-architektur) +- [Notification Counts](#notification-counts) + +--- + +## Pusher registrieren (FCM) + +```dart +// FCM-Token holen +import 'package:firebase_messaging/firebase_messaging.dart'; +final fcmToken = await FirebaseMessaging.instance.getToken(); + +// Pusher beim Homeserver registrieren +await client.postPusher( + Pusher( + pushkey: fcmToken!, + kind: 'http', + appId: 'chat.pyramid.pyramid', // Unique App-ID (muss zu Push-Gateway passen) + appDisplayName: 'Pyramid', + deviceDisplayName: 'Mein Android', + lang: 'de', + data: PusherData( + url: Uri.parse('https://push.example.org/_matrix/push/v1/notify'), + format: 'event_id_only', // Empfohlen (nur Event-ID, nicht der Inhalt) + ), + ), + append: true, // Nicht andere Pusher dieses Geräts löschen +); + +// Token-Refresh überwachen +FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async { + await client.postPusher( + Pusher(pushkey: newToken, /* ... */ ), + append: true, + ); +}); +``` + +--- + +## Pusher abrufen & verwalten + +```dart +// Alle Pusher dieses Users +final pushers = await client.getPushers() ?? []; +for (final pusher in pushers) { + print(pusher.appId); + print(pusher.pushkey); + print(pusher.data.url); +} + +// Pusher entfernen +await client.deletePusher( + Pusher( + pushkey: fcmToken, + appId: 'chat.pyramid.pyramid', + kind: 'http', // Pflichtfeld beim Löschen + // ... + ), +); + +// Beim Logout alle Pusher entfernen +final myPushers = (await client.getPushers() ?? []) + .where((p) => p.appId == 'chat.pyramid.pyramid'); +for (final p in myPushers) { + await client.deletePusher(p); +} +``` + +--- + +## Push Rules + +```dart +// Alle Push Rules abrufen +final ruleset = await client.getPushRules(); + +// Globale Regeln +for (final rule in ruleset.global.content ?? []) { + print(rule.ruleId); + print(rule.enabled); + print(rule.actions); +} + +// Kategorien der Regeln: +// .override — höchste Priorität (z.B. DND-Modus) +// .content — Inhalt-basiert (z.B. eigener Name erwähnt) +// .room — Raum-spezifisch +// .sender — Sender-spezifisch +// .underride — niedrigste Priorität (Fallback) + +// Push Rule aktivieren/deaktivieren +await client.setPushRuleEnabled( + 'global', + PushRuleKind.override, + '.m.rule.master', // DND: stumm schalten + false, // false = Regel deaktivieren +); + +// Eigene Push Rule erstellen (z.B. Raum stumm schalten) +await client.setPushRule( + 'global', + PushRuleKind.room, + '!roomId:server', // RuleId = RoomId für Raum-Regeln + [PushRuleAction.dontNotify], +); + +// Push Rule löschen +await client.deletePushRule( + 'global', + PushRuleKind.room, + '!roomId:server', +); + +// Standard-Rules für Erwähnungen +// Pyramid prüft lokal ob ein Event eine Mention enthält: +final myId = client.userID!; +final mentions = event.content + .tryGetMap('m.mentions') + ?.tryGetList('user_ids') ?? []; +final isMentioned = mentions.contains(myId) || + event.body.contains(myId); + +// Raum stumm schalten +await client.setPushRule( + 'global', + PushRuleKind.override, + '!roomId:server', + [PushRuleAction.dontNotify], + conditions: [ + PushCondition(kind: 'event_match', key: 'room_id', pattern: '!roomId:server'), + ], +); +``` + +--- + +## onNotification Stream + +```dart +// Matrix-SDK emittiert Events die Benachrichtigungen auslösen sollen +// (nach Push-Rule-Matching, entschlüsselt) +client.onNotification.stream.listen((Event event) { + final roomId = event.room.id; + final room = client.getRoomById(roomId)!; + final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId); + + final title = room.isDirectChat + ? sender.displayName ?? event.senderId + : '${sender.displayName} · ${room.getLocalizedDisplayname()}'; + final body = event.body; + + // Lokale Benachrichtigung anzeigen + showLocalNotification(title, body, roomId); +}); +``` + +--- + +## Lokale Benachrichtigungen (Android) + +```dart +// Siehe auch: lib/core/notification_service.dart und PushService.kt + +// flutter_local_notifications initialisieren +final plugin = FlutterLocalNotificationsPlugin(); +await plugin.initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@drawable/ic_notification'), + ), + onDidReceiveNotificationResponse: _handleNotificationTap, + onDidReceiveBackgroundNotificationResponse: handleBackgroundResponse, +); + +// Notification-Kanal erstellen (Android 8+) +await plugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel( + const AndroidNotificationChannel( + 'pyramid_messages', + 'Nachrichten', + description: 'Neue Nachrichten', + importance: Importance.high, + ), + ); + +// Benachrichtigung anzeigen (mit Reply-Action) +await plugin.show( + notifId, + 'Absender', + 'Nachrichtentext', + NotificationDetails( + android: AndroidNotificationDetails( + 'pyramid_messages', + 'Nachrichten', + importance: Importance.high, + priority: Priority.high, + icon: '@drawable/ic_notification', + actions: [ + AndroidNotificationAction( + 'reply', 'Antworten', + inputs: [AndroidNotificationActionInput(label: 'Antworten…')], + allowGeneratedReplies: true, + showsUserInterface: false, // BroadcastReceiver-Pfad + ), + AndroidNotificationAction('read', 'Gelesen', + showsUserInterface: false), + ], + ), + ), + payload: roomId, +); + +// Benachrichtigung schließen +await plugin.cancel(notifId); +``` + +--- + +## Benachrichtigungen: Pyramid-Architektur + +``` +FCM-Payload → PushService.kt (Kotlin, kein Dart-Overhead) + │ + ├─ App im Vordergrund? → Heartbeat-Check → Skip + │ + ├─ HTTP-Fetch: Absender + Raumname (ohne Entschlüsselung möglich) + │ + ├─ Native Notification anzeigen: "Neue Nachricht" + │ └─ Reply-Action → getActivity(MainActivity) → onNewIntent + │ + └─ Flutter-Engine im Cache? → MethodChannel "decryptAndUpdateNotification" + └─ Dart: decryptAndUpdateNotification() → showNativeNotification() [mit echtem Text] + +Reply-Flow: + User tippt Antwort → Reply-PendingIntent (getActivity) + → MainActivity.onNewIntent → MethodChannel "replyFromNotification" + → Dart: room.sendTextEvent(text) + → moveTaskToBack(true) +``` + +```dart +// Heartbeat (App aktiv?) setzen: +void _pingHeartbeat() { + SharedPreferences.getInstance().then( + (p) => p.setInt('notif_app_heartbeat', DateTime.now().millisecondsSinceEpoch), + ); +} + +// Heartbeat löschen (App im Hintergrund): +void setAppForeground(bool value) { + if (!value) { + SharedPreferences.getInstance() + .then((p) => p.setInt('notif_app_heartbeat', 0)); + } +} +``` + +--- + +## Notification Counts + +```dart +// Pro Raum +print(room.notificationCount); // Ungelesene Nachrichten +print(room.highlightCount); // Mentions / Highlights + +// Gesamt +final totalUnread = client.rooms.fold( + 0, (sum, r) => sum + r.notificationCount); +final totalHighlights = client.rooms.fold( + 0, (sum, r) => sum + r.highlightCount); + +// Badge für Windows Taskbar +import 'package:windows_taskbar/windows_taskbar.dart'; +if (totalUnread > 0) { + WindowsTaskbar.setProgressMode(TaskbarProgressMode.error); +} else { + WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress); +} +``` diff --git a/docs/matrix-sdk/08-voip.md b/docs/matrix-sdk/08-voip.md new file mode 100644 index 0000000..8a24b67 --- /dev/null +++ b/docs/matrix-sdk/08-voip.md @@ -0,0 +1,422 @@ +# 08 — VoIP, Calls & LiveKit (MatrixRTC) + +--- + +## Inhaltsverzeichnis +- [Architektur-Überblick](#architektur-überblick) +- [1:1 Call (Legacy m.call)](#11-call-legacy-mcall) +- [MatrixRTC (Gruppen-Calls)](#matrixrtc-gruppen-calls) +- [LiveKit-Integration](#livekit-integration) +- [Klingel-Flow](#klingel-flow) +- [WebRTC Setup in Flutter](#webrtc-setup-in-flutter) +- [Call-State Management](#call-state-management) +- [Pyramid VoIP-Architektur](#pyramid-voip-architektur) + +--- + +## Architektur-Überblick + +``` +Matrix Signaling (to-device / room events) + │ + ▼ + MatrixRTC Layer + ┌─────────────────────────────────┐ + │ com.famedly.call.member Events │ + │ (Membership, Backend, Expiry) │ + └──────────────┬──────────────────┘ + │ + ┌────────┴────────┐ + │ │ + Mesh (P2P) LiveKit (SFU) + WebRTC direct livekit-server + (1:1, max ~4) (skalierbar, n:m) +``` + +--- + +## 1:1 Call (Legacy m.call) + +```dart +// ── Anruf starten ──────────────────────────────────────────────────────── +// Wird vom SDK über room events (m.call.invite, m.call.candidates, +// m.call.answer, m.call.hangup) signalisiert. + +// Anruf-Einladung senden (nach WebRTC offer) +await room.sendEvent( + { + 'call_id': 'eindeutige-call-id', + 'version': 1, + 'lifetime': 60000, // ms, wie lange das Invite gültig ist + 'offer': { + 'type': 'offer', + 'sdp': sdpString, // WebRTC SDP Offer + }, + }, + EventTypes.CallInvite, // 'm.call.invite' +); + +// ICE-Kandidaten senden +await room.sendEvent( + { + 'call_id': callId, + 'version': 1, + 'candidates': [ + { + 'candidate': 'candidate:...', + 'sdpMid': '0', + 'sdpMLineIndex': 0, + }, + ], + }, + EventTypes.CallCandidates, // 'm.call.candidates' +); + +// Anruf annehmen (SDP Answer) +await room.sendEvent( + { + 'call_id': callId, + 'version': 1, + 'answer': { + 'type': 'answer', + 'sdp': sdpAnswerString, + }, + }, + EventTypes.CallAnswer, // 'm.call.answer' +); + +// Auflegen +await room.sendEvent( + { + 'call_id': callId, + 'version': 1, + 'reason': 'user_hangup', // oder 'ice_failed', 'invite_timeout' + }, + EventTypes.CallHangup, // 'm.call.hangup' +); + +// Ablehnen (Reject) +await room.sendEvent( + { + 'call_id': callId, + 'version': 1, + 'reason': 'user_hangup', + }, + EventTypes.CallReject, // 'm.call.reject' +); + +// Eingehende Anrufe überwachen +client.onCallInvite.stream.listen((CallInvite invite) { + print('Eingehender Anruf von ${invite.roomId}'); + print('Call-ID: ${invite.callId}'); +}); +``` + +--- + +## MatrixRTC (Gruppen-Calls) + +```dart +// MatrixRTC verwendet com.famedly.call.member State-Events + +// ── Call beitreten (Membership senden) ─────────────────────────────────── + +final myDeviceId = client.deviceID!; +final callId = room.id; // Normalerweise = Raum-ID + +// Membership-Event senden (signalisiert "ich bin im Call") +await room.client.sendState( + room.id, + 'org.matrix.msc3401.call.member', // oder 'com.famedly.call.member' + { + 'application': 'm.call', + 'call_id': callId, + 'device_id': myDeviceId, + 'expires_ts': DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch, + 'backend': { + 'type': 'm.sfu', // LiveKit + 'livekit_service_url': 'https://livekit.example.org', + }, + // Für Mesh: + // 'backend': {'type': 'm.mesh'}, + 'membershipID': 'eindeutige-membership-id', + }, + stateKey: '$myDeviceId:$callId', +); + +// ── Call verlassen (Membership entfernen) ───────────────────────────────── + +await room.client.sendState( + room.id, + 'org.matrix.msc3401.call.member', + {}, // Leeres Content = Austritt + stateKey: '$myDeviceId:$callId', +); + +// ── Aktive Call-Mitglieder auflesen ────────────────────────────────────── + +final callStates = room.states['org.matrix.msc3401.call.member']; +final now = DateTime.now().millisecondsSinceEpoch; +final activeMembers = callStates?.values.where((event) { + final expiresTs = event.content['expires_ts'] as int? ?? 0; + return expiresTs > now; // Nicht abgelaufen +}).toList() ?? []; + +for (final member in activeMembers) { + print(member.content['device_id']); + print(member.stateKey); // 'deviceId:callId' +} +``` + +--- + +## LiveKit-Integration + +```dart +// LiveKit-Token vom Matrix-Homeserver anfragen (OpenID) +// Dieser Flow authentifiziert den User beim LiveKit-Server ohne eigenes Passwort. + +// 1. OpenID-Token vom Homeserver holen +final openIdToken = await client.requestOpenIdToken(client.userID!); +// openIdToken.accessToken, .matrixServerName + +// 2. LiveKit-Token beim MatrixRTC-Service tauschen +// (Pyramid sendet openIdToken an livekit_service_url des Raums) +final response = await http.post( + Uri.parse('https://livekit-service.example.org/sfu/get'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'room': room.id, + 'openid_token': { + 'access_token': openIdToken.accessToken, + 'token_type': 'Bearer', + 'matrix_server_name': openIdToken.matrixServerName, + }, + 'device_id': client.deviceID, + }), +); +final livekitToken = jsonDecode(response.body)['token']; + +// 3. Mit LiveKit verbinden +import 'package:livekit_client/livekit_client.dart'; +final room = await LiveKitClient.connect( + 'wss://livekit.example.org', + livekitToken, + roomOptions: const RoomOptions( + adaptiveStream: true, + dynacast: true, + defaultVideoPublishOptions: VideoPublishOptions( + simulcast: true, + ), + ), +); + +// 4. Mikrofon aktivieren +await room.localParticipant?.setMicrophoneEnabled(true); + +// 5. Kamera aktivieren (Video-Call) +await room.localParticipant?.setCameraEnabled(true); + +// 6. Teilnehmer observieren +room.addListener(() { + for (final participant in room.participants.values) { + print(participant.identity); // Matrix User-ID + for (final track in participant.videoTracks) { + // Widget: VideoTrackRenderer(track.track!) + } + } +}); +``` + +--- + +## Klingel-Flow + +```dart +// ── Anruf initiieren ────────────────────────────────────────────────────── + +// 1. Membership-Event senden (MatrixRTC) +// 2. To-Device-Event senden für Push-Benachrichtigung beim Empfänger + +// Klingelanruf via to-device-Event +await client.sendToDevice( + 'com.famedly.call', // Event-Typ + { + otherUserId: { + '*': { // Alle Geräte des Users + 'call_id': callId, + 'room_id': room.id, + 'type': 'invite', + 'sender_session_id': client.deviceID, + }, + }, + }, +); + +// ── Eingehenden Anruf empfangen ─────────────────────────────────────────── + +client.onToDeviceEvent.stream + .where((e) => e.type == 'com.famedly.call') + .listen((event) { + final callId = event.content['call_id']; + final roomId = event.content['room_id']; + final type = event.content['type']; // 'invite' | 'hangup' + // Klingelton abspielen, UI anzeigen +}); + +// ── Anruf ablehnen (während Klingeln) ──────────────────────────────────── + +await client.sendToDevice( + 'com.famedly.call', + { + callerId: {'*': {'call_id': callId, 'type': 'reject'}}, + }, +); +``` + +--- + +## WebRTC Setup in Flutter + +```dart +// flutter_webrtc Package +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +// Media-Stream holen +final stream = await navigator.mediaDevices.getUserMedia({ + 'audio': true, + 'video': false, // true für Video-Call +}); + +// PeerConnection erstellen +final pc = await createPeerConnection({ + 'iceServers': [ + {'urls': 'stun:stun.matrix.org:3478'}, + // TURN-Server (für NAT-Traversal): + { + 'urls': 'turn:turn.example.org:5349', + 'username': 'user', + 'credential': 'pass', + }, + ], + 'iceCandidatePoolSize': 10, +}); + +// Track hinzufügen +for (final track in stream.getTracks()) { + await pc.addTrack(track, stream); +} + +// ICE-Kandidaten sammeln +pc.onIceCandidate = (RTCIceCandidate candidate) { + // Kandidaten via Matrix senden + sendIceCandidates([candidate]); +}; + +// Angebot erstellen +final offer = await pc.createOffer(); +await pc.setLocalDescription(offer); +// offer.sdp → via Matrix senden + +// Angebot annehmen +await pc.setRemoteDescription(RTCSessionDescription(sdpOffer, 'offer')); +final answer = await pc.createAnswer(); +await pc.setLocalDescription(answer); +// answer.sdp → via Matrix senden + +// Eingehende Streams +pc.onTrack = (RTCTrackEvent event) { + final remoteStream = event.streams.first; + // Widget: RTCVideoRenderer() mit remoteStream +}; + +// Aufräumen +await pc.close(); +await stream.dispose(); +``` + +--- + +## Call-State Management + +```dart +// Call-Zustand in Pyramid +enum CallState { + idle, + calling, // Anruf geht raus + ringing, // Eingehender Anruf + connecting, // ICE/SDP in Bearbeitung + connected, // Verbunden + ended, +} + +// Stummschalten +final audioTrack = localStream.getAudioTracks().first; +audioTrack.enabled = false; // Mute +audioTrack.enabled = true; // Unmute + +// Lautsprecher vs. Hörer (Android/iOS) +// flutter_webrtc kümmert sich darum via Helper.setSpeakerphoneOn + +// DTMF (Zifferntasten) +final sender = pc.getSenders().firstWhere( + (s) => s.track?.kind == 'audio'); +await sender.dtmf?.insertDTMF('1'); + +// Screen-Sharing starten +final screenStream = await navigator.mediaDevices.getDisplayMedia({ + 'video': true, + 'audio': false, +}); +final screenTrack = screenStream.getVideoTracks().first; +final videoSender = pc.getSenders() + .firstWhere((s) => s.track?.kind == 'video'); +await videoSender.replaceTrack(screenTrack); +``` + +--- + +## Pyramid VoIP-Architektur + +``` +Pyramid Call-Stack: +┌─────────────────────────────────────────────────────┐ +│ Dart (Flutter) │ +│ lib/features/call/ │ +│ call_notifier.dart — Zustand, Aktionen │ +│ call_page.dart — UI (Kamera/Mic Controls) │ +│ livekit_service.dart — LiveKit-Token-Tausch │ +├─────────────────────────────────────────────────────┤ +│ Matrix SDK │ +│ room.sendState(com.famedly.call.member) │ +│ client.sendToDevice(com.famedly.call) │ +│ client.requestOpenIdToken() │ +├─────────────────────────────────────────────────────┤ +│ LiveKit Client (livekit_client package) │ +│ LiveKitClient.connect(url, token) │ +│ room.localParticipant.setMicrophoneEnabled() │ +│ VideoTrackRenderer Widget │ +├─────────────────────────────────────────────────────┤ +│ Native (Android/iOS) │ +│ FlutterForegroundService — hält Call am Leben │ +│ Klingelton via AudioManager │ +└─────────────────────────────────────────────────────┘ + +TURN/STUN-Server Konfiguration (from Matrix): +await client.getTurnServer() → + TurnServerCredentials( + username, password, ttl, + uris: ['turn:turn.example.org:5349?transport=udp'] + ) +``` + +```dart +// TURN-Credentials vom Homeserver holen +final turn = await client.getTurnServer(); +final iceServers = [ + {'urls': turn.uris, 'username': turn.username, 'credential': turn.password}, + {'urls': 'stun:stun.matrix.org:3478'}, +]; +``` diff --git a/docs/matrix-sdk/08b-livekit-streaming.md b/docs/matrix-sdk/08b-livekit-streaming.md new file mode 100644 index 0000000..50c26b8 --- /dev/null +++ b/docs/matrix-sdk/08b-livekit-streaming.md @@ -0,0 +1,1487 @@ +# 08b — LiveKit Streaming: Vollständige Implementierungsanleitung + +> **Ziel:** Echtzeit Audio/Video-Calls und Screen-Sharing in Pyramid via LiveKit SFU, +> authentifiziert über MatrixRTC (Matrix-Standard), selbst gehostet auf dem Raspberry Pi. + +--- + +## Inhaltsverzeichnis +- [Architektur-Überblick](#architektur-überblick) +- [Selbst-gehostete Infrastruktur](#selbst-gehostete-infrastruktur) +- [LiveKit-Server Setup (Raspberry Pi)](#livekit-server-setup-raspberry-pi) +- [LiveKit JWT Service (lk-jwt-service)](#livekit-jwt-service-lk-jwt-service) +- [Synapse MatrixRTC Konfiguration](#synapse-matrixrtc-konfiguration) +- [TURN-Server Setup (coturn)](#turn-server-setup-coturn) +- [Nginx Reverse-Proxy](#nginx-reverse-proxy) +- [Flutter: LiveKit-Paket & Abhängigkeiten](#flutter-livekit-paket--abhängigkeiten) +- [MatrixRTC Membership Flow (Signaling)](#matrixrtc-membership-flow-signaling) +- [LiveKit-Token holen (OpenID-Austausch)](#livekit-token-holen-openid-austausch) +- [LiveKit Room verbinden](#livekit-room-verbinden) +- [Audio-Call implementieren](#audio-call-implementieren) +- [Video-Call implementieren](#video-call-implementieren) +- [Screen-Sharing implementieren](#screen-sharing-implementieren) +- [Klingel-System (Ringing)](#klingel-system-ringing) +- [Call-State-Management (Riverpod)](#call-state-management-riverpod) +- [Call-UI Komponenten](#call-ui-komponenten) +- [Hintergrund-Service (Android)](#hintergrund-service-android) +- [Komplettes Dart-Implementierungs-Template](#komplettes-dart-implementierungs-template) +- [Troubleshooting & Diagnose](#troubleshooting--diagnose) + +--- + +## Architektur-Überblick + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Pyramid App (Flutter) │ +│ │ +│ MatrixRTC Signaling LiveKit Media │ +│ ┌──────────────────┐ ┌─────────────────────┐ │ +│ │ Matrix SDK │ │ livekit_client pkg │ │ +│ │ - call.member │ │ - Audio/Video tracks │ │ +│ │ state events │ │ - Screen share │ │ +│ │ - to-device msgs │ │ - Data channels │ │ +│ └──────┬───────────┘ └──────────┬──────────┘ │ +│ │ │ │ +└─────────┼────────────────────────────────┼──────────────────────┘ + │ HTTPS/WSS │ WSS (WebSocket Secure) + ▼ ▼ +┌─────────────────────┐ ┌──────────────────────────────┐ +│ Synapse (Matrix HS)│ │ LiveKit Server (SFU) │ +│ matrix.steggi- │ │ livekit.steggi-matrix.work │ +│ matrix.work │ │ Port: 7880 (HTTPS/WS) │ +│ │ │ Port: 7881 (TURN/TLS) │ +│ OpenID Token API │ │ Port: 50100-50200 (UDP RTP) │ +└────────┬────────────┘ └───────────────┬──────────────┘ + │ OpenID Token │ + ▼ │ +┌────────────────────┐ │ +│ lk-jwt-service │───────── JWT Token ───────┘ +│ (Token Exchange) │ +│ Port: 8080 │ +└────────────────────┘ + │ + ▼ +┌────────────────────┐ +│ coturn (TURN) │ +│ Port: 3478 UDP │ +│ Port: 5349 TLS │ +└────────────────────┘ + +Nginx Reverse Proxy (Port 443): + /livekit/ → LiveKit :7880 + /lk-jwt/ → lk-jwt-service :8080 + (coturn läuft direkt auf 3478/5349) +``` + +--- + +## Selbst-gehostete Infrastruktur + +### Benötigte Komponenten + +| Dienst | Zweck | Port | Software | +|--------|-------|------|----------| +| **LiveKit Server** | SFU — verteilt Media-Streams | 7880 (WS/HTTP), 7881 (TURN/TLS), 50100-50200 UDP | `livekit/livekit-server` | +| **lk-jwt-service** | Token-Austausch OpenID → LiveKit JWT | 8080 | `element-hq/lk-jwt-service` | +| **coturn** | TURN-Server für NAT-Traversal | 3478 UDP, 5349 TLS | `coturn` | +| **Nginx** | Reverse Proxy + SSL-Termination | 443 | `nginx` | +| **Synapse** | Matrix Homeserver (bereits vorhanden) | 8448 | ✓ läuft | + +### DNS-Einträge (Beispiel für steggi-matrix.work) +``` +livekit.steggi-matrix.work A +turn.steggi-matrix.work A +``` + +--- + +## LiveKit-Server Setup (Raspberry Pi) + +### Installation + +```bash +# LiveKit Binary herunterladen (ARM64 für Raspberry Pi 4/5) +curl -sSL https://get.livekit.io | bash +# oder manuell: +wget https://github.com/livekit/livekit/releases/latest/download/livekit_linux_arm64.tar.gz +tar xzf livekit_linux_arm64.tar.gz +sudo mv livekit-server /usr/local/bin/ + +# API-Key und Secret generieren +livekit-server generate-keys +# Ausgabe: +# API Key: APIxxxxxxxxxxxxxxxxxx +# API Secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# → SICHER AUFBEWAHREN! +``` + +### Konfiguration: `/etc/livekit/livekit.yaml` + +```yaml +port: 7880 +bind_addresses: + - "" # Alle Interfaces + +# API-Schlüssel (aus generate-keys) +keys: + APIxxxxxxxxxxxxxxxxxx: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# WebRTC-Einstellungen +rtc: + tcp_port: 7881 # TURN over TLS + port_range_start: 50100 + port_range_end: 50200 + # Öffentliche IP des Raspberry Pi (wichtig für NAT!) + use_external_ip: true + # STUN-Server für ICE (externe Erreichbarkeit prüfen) + stun_servers: + - stun:stun.l.google.com:19302 + +# TURN eingebaut (LiveKit kann selbst TURN machen) +turn: + enabled: true + domain: livekit.steggi-matrix.work + tls_port: 5349 # TURN über TLS + udp_port: 3478 + +# Logging +logging: + level: info + pion_level: error # WebRTC-interne Logs reduzieren + +# Redis (optional, für Cluster — beim Pi nicht nötig) +# redis: +# address: localhost:6379 +``` + +### Systemd Service: `/etc/systemd/system/livekit.service` + +```ini +[Unit] +Description=LiveKit SFU Server +After=network.target +Wants=network-online.target + +[Service] +Type=simple +User=livekit +Group=livekit +ExecStart=/usr/local/bin/livekit-server --config /etc/livekit/livekit.yaml +Restart=on-failure +RestartSec=5 +LimitNOFILE=65536 + +# Logs +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +``` + +```bash +# User anlegen und Service aktivieren +sudo useradd -r -s /bin/false livekit +sudo systemctl daemon-reload +sudo systemctl enable livekit +sudo systemctl start livekit +sudo systemctl status livekit + +# Logs verfolgen +sudo journalctl -u livekit -f +``` + +--- + +## LiveKit JWT Service (lk-jwt-service) + +Dieser Service tauscht Matrix OpenID-Tokens gegen LiveKit JWTs aus. +Das ist die **Brücke zwischen Matrix-Authentifizierung und LiveKit**. + +### Installation + +```bash +# Go installieren (falls nicht vorhanden) +# Für Raspberry Pi (ARM64): +wget https://go.dev/dl/go1.22.0.linux-arm64.tar.gz +sudo tar -C /usr/local -xzf go1.22.0.linux-arm64.tar.gz +export PATH=$PATH:/usr/local/go/bin + +# lk-jwt-service bauen +git clone https://github.com/element-hq/lk-jwt-service +cd lk-jwt-service +go build -o lk-jwt-service . +sudo mv lk-jwt-service /usr/local/bin/ +``` + +### Konfiguration: `/etc/lk-jwt-service/config.yaml` + +```yaml +# LiveKit-Verbindung +livekit_url: wss://livekit.steggi-matrix.work +livekit_key: APIxxxxxxxxxxxxxxxxxx # selber Key wie in livekit.yaml +livekit_secret: xxxxxxxxxxxxxxxxxxxxx # selbes Secret + +# Matrix-Homeserver für OpenID-Validierung +matrix_server: https://matrix.steggi-matrix.work + +# Welche Matrix-Homeserver dürfen den Service nutzen +allowed_matrix_servers: + - steggi-matrix.work + +# Server-Port +port: 8080 +``` + +### Systemd Service: `/etc/systemd/system/lk-jwt-service.service` + +```ini +[Unit] +Description=LiveKit JWT Token Service for MatrixRTC +After=network.target livekit.service + +[Service] +Type=simple +User=livekit +ExecStart=/usr/local/bin/lk-jwt-service --config /etc/lk-jwt-service/config.yaml +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable lk-jwt-service +sudo systemctl start lk-jwt-service +``` + +### API-Endpoint des lk-jwt-service + +``` +POST /sfu/get +Content-Type: application/json + +{ + "room": "!roomId:server", + "openid_token": { + "access_token": "...", ← von client.requestOpenIdToken() + "token_type": "Bearer", + "matrix_server_name": "steggi-matrix.work" + }, + "device_id": "PYRAMID_DEVICE_ID" +} + +Antwort: +{ + "url": "wss://livekit.steggi-matrix.work", + "jwt": "eyJ..." ← LiveKit JWT Token +} +``` + +--- + +## Synapse MatrixRTC Konfiguration + +In der Synapse-Konfiguration (`homeserver.yaml`): + +```yaml +# MatrixRTC / LiveKit Integration +experimental_features: + msc3401_enabled: true # MatrixRTC Group Calls + msc4140_enabled: true # Delayed events (für Membership-Cleanup) + +# Erlaubt das Senden von com.famedly.call.member State-Events +rc_message: + per_second: 100 + burst_count: 200 + +# LiveKit-Service URL im .well-known bekannt machen (MSC4143) +# → client kann automatisch den richtigen JWT-Service finden +``` + +### `.well-known/matrix/client` erweitern + +Nginx liefert diese JSON-Datei. Ergänze LiveKit-Info: + +```json +{ + "m.homeserver": { + "base_url": "https://matrix.steggi-matrix.work" + }, + "org.matrix.msc4143.rtc_foci": [ + { + "type": "livekit", + "livekit_service_url": "https://livekit.steggi-matrix.work/lk-jwt" + } + ] +} +``` + +--- + +## TURN-Server Setup (coturn) + +LiveKit hat einen eingebauten TURN-Server, aber coturn als Fallback ist empfohlen. + +```bash +sudo apt install coturn -y +``` + +### `/etc/turnserver.conf` + +```conf +# Listening +listening-port=3478 +tls-listening-port=5349 +listening-ip=0.0.0.0 + +# Externe IP (Raspberry Pi öffentliche IP) +external-ip= + +# Realm +realm=turn.steggi-matrix.work +server-name=turn.steggi-matrix.work + +# TLS-Zertifikate (Let's Encrypt) +cert=/etc/letsencrypt/live/turn.steggi-matrix.work/fullchain.pem +pkey=/etc/letsencrypt/live/turn.steggi-matrix.work/privkey.pem + +# Auth (shared secret für Matrix) +use-auth-secret +static-auth-secret=ZUFAELLIGES_LANGES_GEHEIMNIS_HIER + +# Relay-Ports +min-port=49152 +max-port=65535 + +# Sicherheit +no-multicast-peers +no-loopback-peers +denied-peer-ip=10.0.0.0-10.255.255.255 +denied-peer-ip=192.168.0.0-192.168.255.255 +denied-peer-ip=172.16.0.0-172.31.255.255 + +# Logging +log-file=/var/log/turnserver.log +``` + +```bash +# TURN-Service aktivieren +sudo systemctl enable coturn +sudo systemctl start coturn + +# Synapse: TURN-Credentials bekannt machen (in homeserver.yaml) +turn_uris: + - "turn:turn.steggi-matrix.work:3478?transport=udp" + - "turn:turn.steggi-matrix.work:3478?transport=tcp" + - "turns:turn.steggi-matrix.work:5349?transport=tcp" +turn_shared_secret: "ZUFAELLIGES_LANGES_GEHEIMNIS_HIER" +turn_user_lifetime: 86400000 +turn_allow_guests: false +``` + +--- + +## Nginx Reverse-Proxy + +```nginx +# /etc/nginx/sites-available/livekit.steggi-matrix.work + +server { + listen 443 ssl http2; + server_name livekit.steggi-matrix.work; + + ssl_certificate /etc/letsencrypt/live/livekit.steggi-matrix.work/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/livekit.steggi-matrix.work/privkey.pem; + + # LiveKit WebSocket + HTTP API + location / { + proxy_pass http://localhost:7880; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + client_max_body_size 0; + } + + # lk-jwt-service (Token-Austausch) + location /lk-jwt/ { + rewrite ^/lk-jwt/(.*) /$1 break; + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always; + } +} + +# HTTP → HTTPS Redirect +server { + listen 80; + server_name livekit.steggi-matrix.work; + return 301 https://$host$request_uri; +} +``` + +```bash +sudo nginx -t && sudo nginx -s reload +``` + +### Firewall-Regeln + +```bash +# UFW +sudo ufw allow 443/tcp # HTTPS (LiveKit + lk-jwt) +sudo ufw allow 3478/udp # TURN UDP +sudo ufw allow 3478/tcp # TURN TCP +sudo ufw allow 5349/tcp # TURNS TLS +sudo ufw allow 7881/tcp # LiveKit TURN/TLS (eingebaut) +sudo ufw allow 50100:50200/udp # RTP Media Ports + +# Für Raspberry Pi hinter Router: Port-Forwarding nötig! +# Im Router: alle oben genannten Ports → Pi-interne IP weiterleiten +``` + +--- + +## Flutter: LiveKit-Paket & Abhängigkeiten + +### `pubspec.yaml` + +```yaml +dependencies: + livekit_client: ^2.4.0 # LiveKit Flutter SDK + flutter_webrtc: ^0.10.0 # WebRTC (wird von livekit_client benötigt) + permission_handler: ^11.0.0 # Mikrofon/Kamera-Berechtigung + wakelock_plus: ^1.0.0 # Screen während Call aktiv halten + + # Bereits vorhanden: + matrix: ^6.x.x + flutter_riverpod: ^2.x.x +``` + +### Android: `android/app/src/main/AndroidManifest.xml` + +```xml + + + + + + + + + + + + + + + + + + + +``` + +### iOS: `ios/Runner/Info.plist` + +```xml +NSCameraUsageDescription +Pyramid benötigt die Kamera für Video-Calls +NSMicrophoneUsageDescription +Pyramid benötigt das Mikrofon für Audio-Calls +``` + +--- + +## MatrixRTC Membership Flow (Signaling) + +MatrixRTC verwendet State-Events im Raum um anzuzeigen, wer im Call ist. + +```dart +// lib/features/call/matrixrtc_service.dart + +const kCallMemberEventType = 'org.matrix.msc3401.call.member'; + +class MatrixRtcMembership { + final String roomId; + final String deviceId; + final String callId; + final String livekitServiceUrl; + + MatrixRtcMembership({ + required this.roomId, + required this.deviceId, + required this.callId, + required this.livekitServiceUrl, + }); + + // ── Call beitreten: Membership-Event senden ──────────────────────────── + + Future join(Client matrixClient) async { + final expiresTs = DateTime.now() + .add(const Duration(hours: 4)) + .millisecondsSinceEpoch; + + await matrixClient.sendState( + roomId, + kCallMemberEventType, + { + 'application': 'm.call', + 'call_id': callId, + 'device_id': deviceId, + 'expires_ts': expiresTs, + 'membershipID': '${deviceId}_${DateTime.now().millisecondsSinceEpoch}', + 'backend': { + 'type': 'livekit', + 'livekit_service_url': livekitServiceUrl, + }, + }, + stateKey: '$deviceId:$callId', + ); + } + + // ── Call verlassen: Membership entfernen ─────────────────────────────── + + Future leave(Client matrixClient) async { + await matrixClient.sendState( + roomId, + kCallMemberEventType, + {}, // Leer = Austritt + stateKey: '$deviceId:$callId', + ); + } + + // ── Aktive Mitglieder auflisten ─────────────────────────────────────── + + static List> getActiveMembers(Room room, String callId) { + final states = room.states[kCallMemberEventType] ?? {}; + final now = DateTime.now().millisecondsSinceEpoch; + return states.values + .where((e) { + final content = e.content; + return content['call_id'] == callId && + (content['expires_ts'] as int? ?? 0) > now && + content.isNotEmpty; + }) + .map((e) => Map.from(e.content)) + .toList(); + } + + // ── Auf Membership-Änderungen hören ────────────────────────────────── + + static Stream watchCallMembers(Client client, String roomId) { + return client.onSync.stream + .where((update) { + final rooms = update.rooms?.join; + if (rooms == null) return false; + final roomUpdate = rooms[roomId]; + return roomUpdate?.state?.events?.any( + (e) => e.type == kCallMemberEventType) ?? false; + }) + .map((_) {}); + } +} +``` + +--- + +## LiveKit-Token holen (OpenID-Austausch) + +```dart +// lib/features/call/livekit_service.dart + +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:matrix/matrix.dart'; + +class LiveKitService { + static const String _jwtServicePath = '/lk-jwt/sfu/get'; + + /// Tauscht ein Matrix OpenID-Token gegen einen LiveKit JWT. + /// [livekitServiceUrl] kommt aus dem MatrixRTC-Membership-Event des Raums. + static Future getToken({ + required Client matrixClient, + required String roomId, + required String livekitServiceUrl, + }) async { + // 1. OpenID-Token vom Homeserver holen + final openIdToken = await matrixClient.requestOpenIdToken( + matrixClient.userID!, + ); + + // 2. JWT beim lk-jwt-service tauschen + final serviceUrl = Uri.parse(livekitServiceUrl).resolve(_jwtServicePath); + final response = await http.post( + serviceUrl, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'room': roomId, + 'openid_token': { + 'access_token': openIdToken.accessToken, + 'token_type': 'Bearer', + 'matrix_server_name': openIdToken.matrixServerName, + }, + 'device_id': matrixClient.deviceID, + }), + ).timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + throw Exception( + 'lk-jwt-service Fehler ${response.statusCode}: ${response.body}'); + } + + final data = jsonDecode(response.body) as Map; + return LiveKitCredentials( + url: data['url'] as String, + jwt: data['jwt'] as String, + ); + } +} + +class LiveKitCredentials { + final String url; // wss://livekit.steggi-matrix.work + final String jwt; // LiveKit JWT Token + + const LiveKitCredentials({required this.url, required this.jwt}); +} +``` + +--- + +## LiveKit Room verbinden + +```dart +// lib/features/call/livekit_room_manager.dart + +import 'package:livekit_client/livekit_client.dart'; + +class LiveKitRoomManager { + Room? _room; + EventsListener? _listener; + + Room get room => _room!; + bool get isConnected => _room?.connectionState == ConnectionState.connected; + + Future connect({ + required String url, + required String token, + bool audioEnabled = true, + bool videoEnabled = false, + }) async { + _room = Room( + roomOptions: RoomOptions( + adaptiveStream: true, // Qualität automatisch anpassen + dynacast: true, // Inaktive Streams pausieren + defaultAudioPublishOptions: const AudioPublishOptions( + name: 'microphone', + audioBitrate: 24000, // 24 kbps — gut für Sprache + ), + defaultVideoPublishOptions: VideoPublishOptions( + simulcast: true, // Mehrere Qualitätsstufen senden + videoEncoding: VideoEncoding( + maxBitrate: 1200 * 1000, // 1.2 Mbps max + maxFramerate: 30, + ), + ), + defaultScreenShareCaptureOptions: const ScreenShareCaptureOptions( + useiOSBroadcastExtension: true, + params: VideoParameters( + dimensions: VideoDimensions(1920, 1080), + encoding: VideoEncoding(maxBitrate: 3 * 1000 * 1000, maxFramerate: 15), + ), + ), + defaultCameraCaptureOptions: CameraCaptureOptions( + cameraPosition: CameraPosition.front, + params: VideoParameters( + dimensions: VideoDimensions(1280, 720), + encoding: VideoEncoding(maxBitrate: 1200 * 1000, maxFramerate: 30), + ), + ), + ), + ); + + _listener = _room!.createListener(); + + try { + await _room!.connect(url, token, + connectOptions: const ConnectOptions( + autoSubscribe: true, // Alle Tracks automatisch empfangen + rtcConfig: RTCConfiguration( + iceTransportPolicy: RTCIceTransportPolicy.all, + ), + ), + ); + + // Mikrofon aktivieren + if (audioEnabled) { + await _room!.localParticipant?.setMicrophoneEnabled(true); + } + // Kamera aktivieren + if (videoEnabled) { + await _room!.localParticipant?.setCameraEnabled(true); + } + } catch (e) { + await _room?.dispose(); + _room = null; + rethrow; + } + } + + Future disconnect() async { + await _room?.localParticipant?.setMicrophoneEnabled(false); + await _room?.localParticipant?.setCameraEnabled(false); + await _room?.disconnect(); + await _room?.dispose(); + _room = null; + } + + // Event-Listener + void onParticipantConnected(void Function(RemoteParticipant) callback) { + _listener?.on( + (event) => callback(event.participant)); + } + + void onParticipantDisconnected(void Function(RemoteParticipant) callback) { + _listener?.on( + (event) => callback(event.participant)); + } + + void onTrackPublished( + void Function(RemoteParticipant, RemoteTrackPublication) callback) { + _listener?.on((event) => + callback(event.participant, event.publication)); + } + + void onTrackSubscribed( + void Function(Track, TrackPublication, RemoteParticipant) callback) { + _listener?.on( + (event) => callback(event.track, event.publication, event.participant)); + } +} +``` + +--- + +## Audio-Call implementieren + +```dart +// lib/features/call/call_notifier.dart (Audio-Call) + +import 'package:permission_handler/permission_handler.dart'; + +Future startAudioCall({ + required Client matrixClient, + required String roomId, +}) async { + // 1. Berechtigung anfordern + final status = await Permission.microphone.request(); + if (!status.isGranted) throw Exception('Mikrofon-Berechtigung verweigert'); + + // 2. LiveKit-Service-URL aus Raum-Metadaten holen (oder Default) + const livekitServiceUrl = 'https://livekit.steggi-matrix.work'; + + // 3. MatrixRTC Membership senden + final callId = roomId; // Convention: callId = roomId + final membership = MatrixRtcMembership( + roomId: roomId, + deviceId: matrixClient.deviceID!, + callId: callId, + livekitServiceUrl: livekitServiceUrl, + ); + await membership.join(matrixClient); + + // 4. LiveKit-Token holen + final credentials = await LiveKitService.getToken( + matrixClient: matrixClient, + roomId: roomId, + livekitServiceUrl: livekitServiceUrl, + ); + + // 5. Mit LiveKit verbinden (nur Audio) + final manager = LiveKitRoomManager(); + await manager.connect( + url: credentials.url, + token: credentials.jwt, + audioEnabled: true, + videoEnabled: false, + ); + + // 6. Screen aktiv halten + WakelockPlus.enable(); +} + +// Stummschalten +Future setMuted(LiveKitRoomManager manager, bool muted) async { + await manager.room.localParticipant?.setMicrophoneEnabled(!muted); +} + +// Lautsprecher umschalten (Android) +Future setSpeakerOn(bool speakerOn) async { + await Helper.setSpeakerphoneOn(speakerOn); +} +``` + +--- + +## Video-Call implementieren + +```dart +// Widget: Lokale Kamera-Vorschau +class LocalVideoView extends StatelessWidget { + final LocalParticipant participant; + const LocalVideoView({required this.participant, super.key}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: participant.trackPublicationsNotifier, + builder: (ctx, _) { + final videoPub = participant.videoTrackPublications + .where((p) => p.source == TrackSource.camera) + .firstOrNull; + if (videoPub?.track case final VideoTrack track) { + return VideoTrackRenderer(track); + } + return const SizedBox(); + }, + ); + } +} + +// Widget: Remote-Teilnehmer Video +class RemoteVideoView extends StatelessWidget { + final RemoteParticipant participant; + const RemoteVideoView({required this.participant, super.key}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: participant.trackPublicationsNotifier, + builder: (ctx, _) { + final videoPub = participant.videoTrackPublications + .where((p) => + p.source == TrackSource.camera || + p.source == TrackSource.screenShareVideo) + .firstOrNull; + if (videoPub?.track case final VideoTrack track) { + return VideoTrackRenderer(track, fit: RTCVideoViewObjectFit.contain); + } + return Container( + color: Colors.black87, + child: const Center( + child: Icon(Icons.person, size: 80, color: Colors.white38), + ), + ); + }, + ); + } +} + +// Kamera ein-/ausschalten +Future toggleCamera(Room room) async { + final enabled = room.localParticipant?.isCameraEnabled() ?? false; + await room.localParticipant?.setCameraEnabled(!enabled); +} + +// Kamera wechseln (Vorder-/Rückkamera) +Future switchCamera(Room room) async { + final cameraPub = room.localParticipant?.videoTrackPublications + .where((p) => p.source == TrackSource.camera) + .firstOrNull; + if (cameraPub?.track case final LocalVideoTrack track) { + await track.switchCamera(); + } +} +``` + +--- + +## Screen-Sharing implementieren + +```dart +// Android: benötigt MediaProjection-Foreground-Service + +// Screen-Sharing starten +Future startScreenShare(Room room) async { + // Android: MediaProjection-Berechtigung anfordern (System-Dialog) + await room.localParticipant?.setScreenShareEnabled( + true, + captureScreenAudio: true, // Audio mitschneiden + ); +} + +// Screen-Sharing beenden +Future stopScreenShare(Room room) async { + await room.localParticipant?.setScreenShareEnabled(false); +} + +// Android Foreground Service für Screen-Sharing +// android/app/src/main/kotlin/chat/pyramid/pyramid/ScreenShareService.kt: +/* +class ScreenShareService : Service() { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val notification = NotificationCompat.Builder(this, "call_channel") + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("Bildschirm wird geteilt") + .setContentText("Pyramid teilt deinen Bildschirm") + .build() + startForeground(NOTIF_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) + return START_STICKY + } + companion object { const val NOTIF_ID = 2001 } +} +*/ +``` + +--- + +## Klingel-System (Ringing) + +```dart +// lib/features/call/ringing_service.dart + +// ── Anruf ankündigen (to-device Event) ───────────────────────────────── + +Future ringUser({ + required Client matrixClient, + required String targetUserId, + required String roomId, + required String callId, +}) async { + await matrixClient.sendToDevice( + 'com.famedly.call', // Event-Typ (wie element/fluffychat) + { + targetUserId: { + '*': { // Alle Geräte des Users + 'call_id': callId, + 'room_id': roomId, + 'type': 'invite', + 'sender_session_id': matrixClient.deviceID, + 'sender_name': matrixClient.displayName, + 'version': 1, + }, + }, + }, + ); +} + +// ── Klingeln abbrechen ──────────────────────────────────────────────── + +Future cancelRing({ + required Client matrixClient, + required String targetUserId, + required String callId, +}) async { + await matrixClient.sendToDevice( + 'com.famedly.call', + { + targetUserId: { + '*': {'call_id': callId, 'type': 'hangup', 'reason': 'user_hangup'}, + }, + }, + ); +} + +// ── Eingehende to-device Events abhören ────────────────────────────── + +StreamSubscription listenForIncomingCalls( + Client matrixClient, void Function(IncomingCall) onCall) { + return matrixClient.onToDeviceEvent.stream + .where((e) => e.type == 'com.famedly.call') + .listen((event) { + final type = event.content['type'] as String?; + final callId = event.content['call_id'] as String?; + final roomId = event.content['room_id'] as String?; + if (type == 'invite' && callId != null && roomId != null) { + onCall(IncomingCall( + callId: callId, + roomId: roomId, + callerId: event.senderId, + senderName: event.content['sender_name'] as String?, + )); + } + if (type == 'hangup') { + // Klingeln beenden (Anruf abgebrochen) + } + }); +} + +class IncomingCall { + final String callId; + final String roomId; + final String callerId; + final String? senderName; + const IncomingCall({ + required this.callId, + required this.roomId, + required this.callerId, + this.senderName, + }); +} +``` + +--- + +## Call-State-Management (Riverpod) + +```dart +// lib/features/call/call_provider.dart + +enum CallState { idle, calling, ringing, connecting, connected, ended, failed } +enum CallType { audio, video } + +@immutable +class CallStateData { + final CallState state; + final CallType type; + final String? roomId; + final String? callId; + final bool isMuted; + final bool isCameraOff; + final bool isSpeakerOn; + final bool isScreenSharing; + final List participants; + + const CallStateData({ + this.state = CallState.idle, + this.type = CallType.audio, + this.roomId, + this.callId, + this.isMuted = false, + this.isCameraOff = false, + this.isSpeakerOn = false, + this.isScreenSharing = false, + this.participants = const [], + }); + + CallStateData copyWith({ + CallState? state, + CallType? type, + String? roomId, + String? callId, + bool? isMuted, + bool? isCameraOff, + bool? isSpeakerOn, + bool? isScreenSharing, + List? participants, + }) => CallStateData( + state: state ?? this.state, + type: type ?? this.type, + roomId: roomId ?? this.roomId, + callId: callId ?? this.callId, + isMuted: isMuted ?? this.isMuted, + isCameraOff: isCameraOff ?? this.isCameraOff, + isSpeakerOn: isSpeakerOn ?? this.isSpeakerOn, + isScreenSharing: isScreenSharing ?? this.isScreenSharing, + participants: participants ?? this.participants, + ); +} + +class CallNotifier extends StateNotifier { + final Ref _ref; + LiveKitRoomManager? _manager; + MatrixRtcMembership? _membership; + + CallNotifier(this._ref) : super(const CallStateData()); + + Future startCall(String roomId, CallType type) async { + state = state.copyWith(state: CallState.calling, type: type, roomId: roomId); + try { + final client = await _ref.read(matrixClientProvider.future); + final callId = roomId; + const livekitServiceUrl = 'https://livekit.steggi-matrix.work'; + + _membership = MatrixRtcMembership( + roomId: roomId, deviceId: client.deviceID!, + callId: callId, livekitServiceUrl: livekitServiceUrl, + ); + await _membership!.join(client); + + final credentials = await LiveKitService.getToken( + matrixClient: client, roomId: roomId, livekitServiceUrl: livekitServiceUrl, + ); + + _manager = LiveKitRoomManager(); + state = state.copyWith(state: CallState.connecting, callId: callId); + + await _manager!.connect( + url: credentials.url, + token: credentials.jwt, + audioEnabled: true, + videoEnabled: type == CallType.video, + ); + + _manager!.onParticipantConnected((p) { + state = state.copyWith(participants: [...state.participants, p]); + }); + _manager!.onParticipantDisconnected((p) { + state = state.copyWith( + participants: state.participants.where((x) => x.sid != p.sid).toList(), + ); + }); + + WakelockPlus.enable(); + state = state.copyWith(state: CallState.connected); + } catch (e) { + await _cleanup(); + state = state.copyWith(state: CallState.failed); + } + } + + Future hangUp() async { + final client = await _ref.read(matrixClientProvider.future); + await _membership?.leave(client); + await _cleanup(); + state = const CallStateData(state: CallState.ended); + await Future.delayed(const Duration(seconds: 1)); + state = const CallStateData(); + } + + Future toggleMute() async { + final muted = !state.isMuted; + await _manager?.room.localParticipant?.setMicrophoneEnabled(!muted); + state = state.copyWith(isMuted: muted); + } + + Future toggleCamera() async { + final off = !state.isCameraOff; + await _manager?.room.localParticipant?.setCameraEnabled(!off); + state = state.copyWith(isCameraOff: off); + } + + Future toggleSpeaker() async { + final on = !state.isSpeakerOn; + await Helper.setSpeakerphoneOn(on); + state = state.copyWith(isSpeakerOn: on); + } + + Future toggleScreenShare() async { + final sharing = !state.isScreenSharing; + if (_manager != null) { + await _manager!.room.localParticipant?.setScreenShareEnabled(sharing); + } + state = state.copyWith(isScreenSharing: sharing); + } + + Future _cleanup() async { + await _manager?.disconnect(); + _manager = null; + WakelockPlus.disable(); + } +} + +final callProvider = StateNotifierProvider( + (ref) => CallNotifier(ref), +); +``` + +--- + +## Call-UI Komponenten + +```dart +// lib/features/call/call_page.dart + +class CallPage extends ConsumerWidget { + const CallPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final call = ref.watch(callProvider); + final notifier = ref.read(callProvider.notifier); + + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Stack( + children: [ + // ── Haupt-Video (Remote Participant) ────────────────────── + if (call.participants.isNotEmpty) + Positioned.fill( + child: RemoteVideoView(participant: call.participants.first), + ) + else + const Center( + child: Icon(Icons.person, size: 120, color: Colors.white24), + ), + + // ── Lokale Kamera (klein, Overlay) ──────────────────────── + if (call.type == CallType.video) + Positioned( + right: 16, top: 16, + width: 120, height: 180, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: _LocalPreview(), + ), + ), + + // ── Status-Text ─────────────────────────────────────────── + Positioned( + top: 24, left: 0, right: 0, + child: Text( + _statusText(call.state), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 18), + ), + ), + + // ── Call-Controls ───────────────────────────────────────── + Positioned( + bottom: 40, left: 0, right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _CallButton( + icon: call.isMuted ? Icons.mic_off : Icons.mic, + label: call.isMuted ? 'Ton an' : 'Stumm', + color: call.isMuted ? Colors.red : Colors.white24, + onTap: notifier.toggleMute, + ), + _CallButton( + icon: Icons.call_end, + label: 'Auflegen', + color: Colors.red, + size: 72, + onTap: notifier.hangUp, + ), + if (call.type == CallType.video) + _CallButton( + icon: call.isCameraOff + ? Icons.videocam_off + : Icons.videocam, + label: 'Kamera', + color: call.isCameraOff ? Colors.red : Colors.white24, + onTap: notifier.toggleCamera, + ) + else + _CallButton( + icon: call.isSpeakerOn + ? Icons.volume_up + : Icons.volume_down, + label: 'Lautsprecher', + color: Colors.white24, + onTap: notifier.toggleSpeaker, + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _statusText(CallState state) => switch (state) { + CallState.calling => 'Verbinde…', + CallState.connecting => 'Verbinde…', + CallState.connected => 'Verbunden', + CallState.ended => 'Aufgelegt', + CallState.failed => 'Fehler', + _ => '', + }; +} + +class _CallButton extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + final double size; + final VoidCallback onTap; + + const _CallButton({ + required this.icon, required this.label, + required this.color, required this.onTap, + this.size = 56, + }); + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: onTap, + child: CircleAvatar( + radius: size / 2, + backgroundColor: color, + child: Icon(icon, color: Colors.white, size: size * 0.5), + ), + ), + const SizedBox(height: 6), + Text(label, style: const TextStyle(color: Colors.white70, fontSize: 12)), + ], + ); +} +``` + +--- + +## Hintergrund-Service (Android) + +```kotlin +// android/app/src/main/kotlin/chat/pyramid/pyramid/CallForegroundService.kt + +package chat.pyramid.pyramid + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat + +class CallForegroundService : Service() { + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> startCallNotification() + ACTION_STOP -> stopSelf() + } + return START_STICKY + } + + private fun startCallNotification() { + ensureChannel() + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("Pyramid — Aktiver Anruf") + .setContentText("Tippe um zum Anruf zurückzukehren") + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIF_ID, notification, + android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE) + } else { + startForeground(NOTIF_ID, notification) + } + } + + private fun ensureChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, "Anrufe", NotificationManager.IMPORTANCE_HIGH) + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + companion object { + const val ACTION_START = "START_CALL" + const val ACTION_STOP = "STOP_CALL" + const val CHANNEL_ID = "pyramid_calls" + const val NOTIF_ID = 1001 + } +} +``` + +```dart +// Dart: Service starten/stoppen +import 'package:flutter/services.dart'; + +const _callChannel = MethodChannel('chat.pyramid.pyramid/call'); + +Future startCallService() async { + if (!Platform.isAndroid) return; + await _callChannel.invokeMethod('startCallService'); +} + +Future stopCallService() async { + if (!Platform.isAndroid) return; + await _callChannel.invokeMethod('stopCallService'); +} +``` + +--- + +## Troubleshooting & Diagnose + +### Server-Tests + +```bash +# LiveKit-Server erreichbar? +curl https://livekit.steggi-matrix.work/ + +# lk-jwt-service erreichbar? +curl -X POST https://livekit.steggi-matrix.work/lk-jwt/sfu/get \ + -H 'Content-Type: application/json' \ + -d '{"room":"!test:server","openid_token":{"access_token":"test","token_type":"Bearer","matrix_server_name":"steggi-matrix.work"},"device_id":"TEST"}' +# Erwartete Antwort: {"url":"wss://...","jwt":"eyJ..."} + +# TURN-Server Test (stuncheck) +pip install stun-client +python -c "import stun; print(stun.get_ip_info())" + +# LiveKit Logs +sudo journalctl -u livekit -f +# lk-jwt-service Logs +sudo journalctl -u lk-jwt-service -f + +# UDP-Port-Reichbarkeit (von außen) +nmap -sU -p 50100-50110 +``` + +### Häufige Probleme + +| Problem | Ursache | Lösung | +|---------|---------|--------| +| `ICE failed` | UDP-Ports gesperrt | TURN aktivieren, Firewall prüfen | +| Token-Fehler 401 | OpenID-Validierung schlägt fehl | Matrix-Server-URL in lk-jwt-service prüfen | +| `WebSocket closed` | Nginx-Proxy-Timeout | `proxy_read_timeout 3600s` in Nginx | +| Kein Audio | Mikrofon-Berechtigung | `Permission.microphone.request()` | +| `Room not found` | LiveKit-Key falsch | API-Key in livekit.yaml = lk-jwt-service Config | +| Kein Video | Kamera gesperrt | `Permission.camera.request()` | +| Call fällt nach 4h ab | `expires_ts` zu niedrig | Bei 30min vor Ablauf Membership erneuern | + +### Dart-Diagnose-Tool + +```dart +Future> diagnoseLiveKit(Client client, String roomId) async { + final results = {}; + + // 1. OpenID-Token + try { + await client.requestOpenIdToken(client.userID!); + results['openid_token'] = true; + } catch (_) { results['openid_token'] = false; } + + // 2. JWT-Service erreichbar + try { + final r = await http.get( + Uri.parse('https://livekit.steggi-matrix.work/')); + results['livekit_reachable'] = r.statusCode < 500; + } catch (_) { results['livekit_reachable'] = false; } + + // 3. Token-Austausch + try { + await LiveKitService.getToken( + matrixClient: client, + roomId: roomId, + livekitServiceUrl: 'https://livekit.steggi-matrix.work', + ); + results['token_exchange'] = true; + } catch (_) { results['token_exchange'] = false; } + + return results; +} +``` diff --git a/docs/matrix-sdk/09-profile.md b/docs/matrix-sdk/09-profile.md new file mode 100644 index 0000000..f517945 --- /dev/null +++ b/docs/matrix-sdk/09-profile.md @@ -0,0 +1,264 @@ +# 09 — Profil, Präsenz & Account-Daten + +--- + +## Inhaltsverzeichnis +- [Eigenes Profil lesen](#eigenes-profil-lesen) +- [Profil aktualisieren](#profil-aktualisieren) +- [Profil anderer User](#profil-anderer-user) +- [Präsenz (Presence)](#präsenz-presence) +- [Account-Daten (Account Data)](#account-daten-account-data) +- [Ignorierte User](#ignorierte-user) +- [Tags / Favoriten](#tags--favoriten) +- [Account löschen / deaktivieren](#account-löschen--deaktivieren) + +--- + +## Eigenes Profil lesen + +```dart +// Aus dem Client (gecacht) +print(client.userID); // '@alice:server' +print(client.displayName); // 'Alice' +print(client.avatarUrl); // mxc://... + +// Frisch vom Server +final profile = await client.fetchOwnProfile(); +print(profile.displayname); +print(profile.avatarUrl); + +// Avatar als HTTP-URL +final avatarHttp = client.avatarUrl?.getThumbnail( + client, + width: 128, + height: 128, + method: ThumbnailMethod.crop, +); +``` + +--- + +## Profil aktualisieren + +```dart +// Displayname setzen +await client.setDisplayName(client.userID!, 'Neuer Name'); + +// Avatar hochladen und setzen +final avatarBytes = await File('avatar.png').readAsBytes(); +final mxcUri = await client.uploadContent( + avatarBytes, + filename: 'avatar.png', + contentType: 'image/png', +); +await client.setAvatarUrl(client.userID!, mxcUri); + +// Avatar entfernen +await client.setAvatarUrl(client.userID!, null); +``` + +--- + +## Profil anderer User + +```dart +// Profil via API +final profile = await client.getProfileFromUserId('@bob:server'); +print(profile.displayname); +print(profile.avatarUrl); + +// Spezifisches Feld (Matrix 1.16+ API) +final displayname = await client.getProfileField( + '@bob:server', + 'displayname', +); +final avatar = await client.getProfileField( + '@bob:server', + 'avatar_url', +); + +// User in einem Raum (schneller, gecacht) +final user = room.unsafeGetUserFromMemoryOrFallback('@bob:server'); +print(user.displayName); +print(user.avatarUrl); +print(user.id); + +// User-Suche +final results = await client.searchUserDirectory( + 'alice', + limit: 20, +); +for (final user in results.results) { + print(user.userId); + print(user.displayName); + print(user.avatarUrl); +} +``` + +--- + +## Präsenz (Presence) + +```dart +// Eigene Präsenz setzen +await client.setPresence( + client.userID!, + PresenceType.online, // .online | .unavailable | .offline + statusMsg: 'Im Meeting', // Optionaler Status-Text +); + +// Präsenz anderer User abrufen +final presence = await client.fetchCurrentPresence('@bob:server'); +print(presence.presence); // PresenceType +print(presence.currentlyActive); // bool? +print(presence.statusMsg); // String? +print(presence.lastActiveAgo); // int? (Millisekunden seit letzter Aktivität) + +// Präsenz aus Raum-Mitglieder +final user = room.unsafeGetUserFromMemoryOrFallback('@bob:server'); +// Präsenz wird nicht direkt im User gecacht — fetchCurrentPresence nötig + +// Präsenz-Anzeige im UI +String getPresenceText(PresenceType? type, int? lastActiveAgo) { + switch (type) { + case PresenceType.online: return 'Online'; + case PresenceType.unavailable: + return 'Abwesend'; + case PresenceType.offline: + if (lastActiveAgo != null) { + final mins = lastActiveAgo ~/ 60000; + if (mins < 60) return 'Vor $mins Min. aktiv'; + final hours = mins ~/ 60; + if (hours < 24) return 'Vor $hours Std. aktiv'; + return 'Vor ${hours ~/ 24} Tagen aktiv'; + } + return 'Offline'; + default: return ''; + } +} +``` + +--- + +## Account-Daten (Account Data) + +```dart +// Account-Daten sind private, servergespeicherte Einstellungen. +// Bleiben über Geräte hinweg synchron. + +// Lesen +final data = client.accountData['com.example.my_setting']?.content; + +// Schreiben +await client.setAccountData( + client.userID!, + 'com.example.my_setting', + {'key': 'value', 'enabled': true}, +); + +// Wichtige Standard-Account-Daten: +// 'm.direct' → DM-Raumliste +// 'm.push_rules' → Push-Regel-Overrides +// 'm.ignored_user_list' → Ignorierte User +// 'm.identity_server' → Identity-Server-URL +// 'im.vector.setting.breadcrumbs' → FluffyChat letzte Räume + +// Raumspezifische Account-Daten +await client.setRoomAccountData( + client.userID!, + '!roomId:server', + 'm.fully_read', + {'event_id': eventId}, +); + +// Tag für Raum setzen (Favorit, Low-Priority) +await client.setRoomTag( + client.userID!, + '!roomId:server', + 'm.favourite', + TagContent(order: 0.5), +); +await client.setRoomTag(client.userID!, '!roomId:server', 'm.lowpriority', TagContent()); +await client.deleteRoomTag(client.userID!, '!roomId:server', 'm.favourite'); +``` + +--- + +## Ignorierte User + +```dart +// User ignorieren +final ignoredList = client.ignoredUsers; // List +final updated = [...ignoredList, '@spammer:server']; +await client.setAccountData( + client.userID!, + 'm.ignored_user_list', + {'ignored_users': Map.fromIterable(updated, value: (_) => {})}, +); + +// User entblockieren +final filtered = ignoredList.where((id) => id != '@spammer:server').toList(); +await client.setAccountData( + client.userID!, + 'm.ignored_user_list', + {'ignored_users': Map.fromIterable(filtered, value: (_) => {})}, +); + +// Ignorierte User abrufen +final ignored = client.ignoredUsers; // automatisch aus Account-Data +``` + +--- + +## Tags / Favoriten + +```dart +// Raum als Favorit markieren +await room.addTag('m.favourite', order: 0.1); + +// Raum als Low-Priority markieren +await room.addTag('m.lowpriority'); + +// Alle Tags eines Raums +final tags = room.tags; // Map +final isFavourite = tags.containsKey('m.favourite'); +final isLowPriority = tags.containsKey('m.lowpriority'); + +// Tag entfernen +await room.removeTag('m.favourite'); + +// Eigene Tags +await room.addTag('com.example.myapp.tag', order: 0.5); + +// Räume nach Tag filtern +final favourites = client.rooms + .where((r) => r.tags.containsKey('m.favourite')) + .toList(); +``` + +--- + +## Account löschen / deaktivieren + +```dart +// Account deaktivieren (PERMANENT, nicht rückgängig!) +await client.uiaRequestBackground( + (auth) => client.deactivateAccount( + auth: auth, + eraseData: true, // true: alle Nachrichten löschen (soweit möglich) + ), +); + +// Passwort ändern (braucht Re-Auth via UIAA) +await client.uiaRequestBackground( + (auth) => client.changePassword( + 'neuesPasswort', + auth: auth, + oldPassword: 'altesPasswort', + ), +); + +// E-Mail-Adresse hinzufügen (über Identity-Server) +// Dies ist ein mehrstufiger UIAA-Flow und erfordert E-Mail-Bestätigung. +// Normalerweise über den Homeserver-spezifischen Registration-Flow. +``` diff --git a/docs/matrix-sdk/10-room-admin.md b/docs/matrix-sdk/10-room-admin.md new file mode 100644 index 0000000..e3515a8 --- /dev/null +++ b/docs/matrix-sdk/10-room-admin.md @@ -0,0 +1,410 @@ +# 10 — Raum-Administration & Moderation + +--- + +## Inhaltsverzeichnis +- [Power Levels vollständig](#power-levels-vollständig) +- [Moderation: Kick, Ban, Unban](#moderation-kick-ban-unban) +- [Server-Side Room Admin](#server-side-room-admin) +- [Raum-Einstellungen komplett](#raum-einstellungen-komplett) +- [Raum-Alias verwalten](#raum-alias-verwalten) +- [Server ACL (IP-Sperren)](#server-acl-ip-sperren) +- [Raum-Audit / State inspizieren](#raum-audit--state-inspizieren) +- [Widgets / Bots verwalten](#widgets--bots-verwalten) +- [Room Tombstone / Upgrade](#room-tombstone--upgrade) +- [Reporting (Melden)](#reporting-melden) + +--- + +## Power Levels vollständig + +```dart +// Power Level eines Users ändern +await room.setPower('@alice:server', 50); // 0=User, 50=Mod, 100=Admin + +// Alle Power Levels auf einmal setzen +await room.client.sendState( + room.id, + EventTypes.RoomPowerLevels, + { + // Welche Power-Level für Standard-Aktionen benötigt werden: + 'ban': 50, + 'kick': 50, + 'invite': 0, + 'redact': 50, + 'events_default': 0, // Nachrichten senden + 'state_default': 50, // State-Events ändern + + // Spezifische Event-Typen: + 'events': { + 'm.room.name': 50, + 'm.room.topic': 50, + 'm.room.avatar': 50, + 'm.room.power_levels': 100, + 'm.room.canonical_alias': 50, + 'm.room.history_visibility': 100, + 'm.room.encryption': 100, + 'm.space.child': 50, + }, + + // User-Overrides: + 'users': { + '@admin:server': 100, + '@moderator:server': 50, + }, + 'users_default': 0, // Alle anderen User + 'notifications': { + 'room': 50, // @room mention power level + }, + }, + stateKey: '', +); + +// Power Level lesen +final plContent = room.getState(EventTypes.RoomPowerLevels)?.content; +final usersMap = plContent?.tryGetMap('users') ?? {}; +final adminPl = usersMap['@alice:server'] as int? ?? 0; + +// Berechtigungen prüfen +room.ownPowerLevel; // int: eigener PL +room.getPowerLevelByUserId('@alice:server'); // int +room.canBan; // bool +room.canKick; // bool +room.canInvite; // bool +room.canRedactEvent(event); // bool +room.canSendEvent(EventTypes.Message); // bool +room.canSendEvent(EventTypes.RoomName); // bool +room.canChangeStateEvent(EventTypes.RoomTopic); // bool +room.canChangeNameAndTopic; // bool +room.canChangeJoinRules; // bool +room.canChangeHistoryVisibility; // bool +``` + +--- + +## Moderation: Kick, Ban, Unban + +```dart +// Kick (User wird entfernt, kann wieder beitreten) +await room.kick('@alice:server', reason: 'Verstoß gegen Regeln'); + +// Ban (User kann nicht mehr beitreten) +await room.ban('@alice:server', reason: 'Dauerhafter Ausschluss'); + +// Unban +await room.unban('@alice:server'); + +// Alle gebannten User anzeigen +final bannedMembers = room.getParticipants( + membershipFilter: [Membership.ban], +); + +// Eigene Berechtigungen prüfen vor Moderation-Aktionen +if (room.canKick) { + await room.kick(userId); +} +if (room.canBan) { + await room.ban(userId); +} + +// User-PL prüfen (kann man diesen User kicken/bannen?) +// Man kann nur User mit niedrigerem PL als dem eigenen moderieren. +final theirPl = room.getPowerLevelByUserId(userId); +final myPl = room.ownPowerLevel; +final canModerate = myPl > theirPl; +``` + +--- + +## Server-Side Room Admin + +> ⚠️ **Pyramid läuft auf Continuwuity** — Synapse Admin API (`/_synapse/admin/`) **existiert nicht!** +> Für vollständige Continuwuity-Admin-Dokumentation: **[→ 13-server-admin-continuwuity.md](13-server-admin-continuwuity.md)** + +```dart +// Server-Admin-Status prüfen (Matrix-Standard, funktioniert überall) +final isAdmin = await client.getIsAdmin(); + +// Für Continuwuity: Admin-Aktionen über Standard Matrix Client API +// oder über den Admin-Room (#admins:steggi-matrix.work) + +// Power Level in einem Raum als Admin setzen: +await client.setRoomStateWithKey( + room.id, + EventTypes.RoomPowerLevels, + '', + { + 'users': {'@admin:server': 100}, + 'users_default': 0, + 'state_default': 50, + 'events_default': 0, + 'ban': 50, 'kick': 50, 'invite': 0, 'redact': 50, + }, +); + +// Raum beitreten (Admin kann invite-only-Räume per API beitreten): +await client.joinRoom(room.id); + +// Space-Child-Relation setzen (Channel einem Space hinzufügen): +await client.setRoomStateWithKey( + spaceId, + EventTypes.SpaceChild, + channelRoomId, + {'via': [client.homeserver.host], 'suggested': false}, +); +``` + +```bash +# Continuwuity Admin HTTP API +# Basis: https://server/_conduwuit/admin/v1/ + +# User zu Server-Admin machen: +curl -X POST "https://server/_conduwuit/admin/v1/make_user_admin" \ + -H "Authorization: Bearer TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"user_id": "@admin:server"}' + +# Power Level in Raum setzen (Standard Matrix API, mit Admin-Token): +curl -X PUT "https://server/_matrix/client/v3/rooms/!roomId:server/state/m.room.power_levels/" \ + -H "Authorization: Bearer ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"users": {"@admin:server": 100}, "users_default": 0, "state_default": 50}' + +# Für Synapse (falls Server-Wechsel zu Synapse): +# curl .../server/_synapse/admin/v1/rooms/{roomId} ← dann wieder relevant +``` + +--- + +## Raum-Einstellungen komplett + +```dart +// Name +await room.setName('Neuer Raumname'); + +// Thema +await room.setDescription('Neues Thema'); + +// Avatar +final mxcUri = await client.uploadContent(imageBytes, filename: 'avatar.png'); +await room.setAvatar(mxcUri); + +// Join-Rules +await room.setJoinRules(JoinRules.invite); // Privat +await room.setJoinRules(JoinRules.public); // Öffentlich +await room.setJoinRules(JoinRules.knock); // Beitrittsanfragen + +// Restricted (Nur Space-Mitglieder können beitreten) +await room.setJoinRules( + JoinRules.restricted, + allow: [ + SpaceRoomJoinRulesAllowInner( + roomId: '!spaceId:server', + type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership, + ), + ], +); + +// History-Visibility +await room.setHistoryVisibility(HistoryVisibility.shared); +// .shared = alle sehen vergangene Nachrichten (nach Join) +// .invited = sieht Nachrichten ab dem Einladungs-Zeitpunkt +// .joined = sieht Nachrichten nur ab dem Beitritts-Zeitpunkt +// .worldReadable = öffentlich lesbar (auch ohne Account) + +// Guest Access +await room.setGuestAccess(GuestAccess.canJoin); // Gäste erlaubt +await room.setGuestAccess(GuestAccess.forbidden); // Keine Gäste + +// E2EE aktivieren (einmalig, nicht deaktivierbar!) +await room.enableEncryption(); + +// Notifications für @room: Welcher PL nötig? +// → über Power Levels 'notifications.room' setzen +``` + +--- + +## Raum-Alias verwalten + +```dart +// Alias erstellen +await client.setRoomAlias('#neuer-alias:server', room.id); + +// Kanonischen Alias setzen (sichtbarer Hauptalias) +await room.client.sendState( + room.id, + EventTypes.RoomCanonicalAlias, + { + 'alias': '#hauptalias:server', + 'alt_aliases': ['#alternativ:server'], + }, + stateKey: '', +); + +// Alias auflösen → Raum-ID +final resolved = await client.getRoomIdByAlias('#alias:server'); +print(resolved.roomId); // !roomId:server + +// Alias löschen +await client.deleteRoomAlias('#alter-alias:server'); + +// Alle Aliases eines Raums +final aliases = (room.getState(EventTypes.RoomCanonicalAlias) + ?.content['alt_aliases'] as List?) + ?.cast() ?? []; +final canonical = room.canonicalAlias; +``` + +--- + +## Server ACL (IP-Sperren) + +```dart +// Server ACL: Welche Server dürfen am Raum teilnehmen? +// Wichtig für öffentliche Räume um Spam-Server zu sperren. + +await room.client.sendState( + room.id, + 'm.room.server_acl', + { + 'allow': ['*'], // Alle erlauben + 'deny': ['spam.example.org', '*.badserver.org'], + 'allow_ip_literals': false, // IP-Adressen als Server-Namen verboten + }, + stateKey: '', +); + +// ACL lesen +final acl = room.getState('m.room.server_acl')?.content; +final denied = (acl?['deny'] as List?)?.cast() ?? []; +``` + +--- + +## Raum-Audit / State inspizieren + +```dart +// Alle State-Events lesen +final states = room.states; +// Map> + +// Spezifischer State +final nameEvent = room.getState(EventTypes.RoomName); +final topicEvent = room.getState(EventTypes.RoomTopic); +final memberEvent = room.getState(EventTypes.RoomMember, '@alice:server'); +final encEvent = room.getState(EventTypes.Encryption); + +// Alle Mitglieder-States +final memberStates = states[EventTypes.RoomMember] ?? {}; +for (final entry in memberStates.entries) { + final mxid = entry.key; // stateKey = MXID + final membership = entry.value.content['membership']; + print('$mxid: $membership'); +} + +// Raum-History vom Server laden (Admin) +final response = await client.request( + RequestType.GET, + '/rooms/${room.id}/state', +); + +// Event-Timeline abrufen (Admin) +final timeline = await client.request( + RequestType.GET, + '/rooms/${room.id}/messages', + query: {'limit': '100', 'dir': 'b'}, +); +``` + +--- + +## Widgets / Bots verwalten + +```dart +// Widget hinzufügen (z.B. Jitsi, Etherpad) +await room.client.sendState( + room.id, + 'm.widget', + { + 'type': 'jitsi', + 'url': 'https://jitsi.example.org/#!roomId', + 'name': 'Jitsi Call', + 'data': {'domain': 'jitsi.example.org'}, + 'waitForIframeLoad': true, + 'creatorUserId': client.userID, + 'id': 'jitsi-widget', + }, + stateKey: 'jitsi-widget', +); + +// Widget entfernen +await room.client.sendState( + room.id, + 'm.widget', + {}, // Leeres Content = entfernen + stateKey: 'jitsi-widget', +); + +// Alle Widgets lesen +final widgets = room.states['m.widget'] ?? {}; +for (final widget in widgets.values) { + print(widget.content['name']); + print(widget.content['url']); +} +``` + +--- + +## Room Tombstone / Upgrade + +```dart +// Raum-Upgrade (Nachfolger-Raum erstellen) +final newRoomId = await client.upgradeRoom( + room.id, + newVersion: '11', // Aktuelle empfohlene Version +); +// SDK sendet m.room.tombstone in alten Raum +// Und m.room.create mit predecessor in neuen Raum +// Alle Mitglieder werden in neuen Raum eingeladen + +// Tombstone lesen +final tombstone = room.getState(EventTypes.RoomTombstone); +if (tombstone != null) { + final replacementRoomId = tombstone.content['replacement_room'] as String; + final serverMessage = tombstone.content['body'] as String; + // → Zum neuen Raum navigieren +} + +// Predecessor lesen (im neuen Raum) +final create = room.getState(EventTypes.RoomCreate); +final predecessor = create?.content['predecessor'] as Map?; +if (predecessor != null) { + final oldRoomId = predecessor['room_id'] as String; + final lastEventId = predecessor['event_id'] as String; +} +``` + +--- + +## Reporting (Melden) + +```dart +// Event melden (z.B. Spam, illegaler Inhalt) +await client.reportContent( + room.id, + event.eventId, + reason: 'Spam/Belästigung', + score: -100, // -100 = sehr schlimm, 0 = nicht schlimm +); + +// User melden (via MSC4151, wenn vom Server unterstützt) +await client.httpClient.post( + Uri.parse('${client.homeserver}/_matrix/client/v3/report/${Uri.encodeComponent("@user:server")}'), + headers: { + 'Authorization': 'Bearer ${client.accessToken}', + 'Content-Type': 'application/json', + }, + body: jsonEncode({'reason': 'Belästigung'}), +); +``` diff --git a/docs/matrix-sdk/11-search.md b/docs/matrix-sdk/11-search.md new file mode 100644 index 0000000..0365ed1 --- /dev/null +++ b/docs/matrix-sdk/11-search.md @@ -0,0 +1,238 @@ +# 11 — Suche & Discovery + +--- + +## Inhaltsverzeichnis +- [Nachrichten in einem Raum suchen](#nachrichten-in-einem-raum-suchen) +- [Globale Nachrichten-Suche](#globale-nachrichten-suche) +- [User-Suche](#user-suche) +- [Öffentliches Raumverzeichnis](#öffentliches-raumverzeichnis) +- [Matrix-URI auflösen](#matrix-uri-auflösen) +- [Raum per Alias finden](#raum-per-alias-finden) + +--- + +## Nachrichten in einem Raum suchen + +```dart +// In der lokalen Timeline suchen (gecacht) +final found = timeline.events + .where((e) => + e.type == EventTypes.Message && + e.body.toLowerCase().contains(query.toLowerCase())) + .toList(); + +// Auf dem Server suchen (Matrix-Suchendpoint) +final result = await client.searchEvents( + Categories( + roomEvents: RoomEventsCriteria( + searchTerm: 'suchbegriff', + roomId: room.id, // optional: nur in einem Raum + orderBy: SearchOrder.recent, // oder .rank + keys: [SearchKey.contentBody], // oder .contentName, .contentTopic + filter: RoomEventFilter( + limit: 20, + rooms: [room.id], + notRooms: [], + ), + ), + ), +); + +// Ergebnisse +for (final result in result.roomEvents?.results ?? []) { + print(result.result.body); + print(result.result.originServerTs); + print(result.result.senderId); +} + +// Paginierung +final nextToken = result.roomEvents?.nextBatch; +if (nextToken != null) { + final more = await client.searchEvents( + /* gleiche Criteria */, + nextBatch: nextToken, + ); +} +``` + +--- + +## Globale Nachrichten-Suche + +```dart +// Über alle Räume suchen +final result = await client.searchEvents( + Categories( + roomEvents: RoomEventsCriteria( + searchTerm: 'flutter matrix', + orderBy: SearchOrder.recent, + includeState: false, + groupings: Groupings( + groupBy: [Group(key: GroupKey.roomId)], // Gruppiert nach Raum + ), + ), + ), +); + +// Gruppen-Ergebnisse +for (final entry in result.roomEvents?.groups?.entries ?? []) { + final groupKey = entry.key; // 'room_id' + for (final roomGroup in entry.value.entries) { + final roomId = roomGroup.key; + print('Treffer in $roomId: ${roomGroup.value.count}'); + // roomGroup.value.results: List (Event-IDs) + } +} +``` + +--- + +## User-Suche + +```dart +// User-Verzeichnis durchsuchen +final response = await client.searchUserDirectory( + 'alice', // Suchbegriff (Name oder MXID) + limit: 20, +); + +for (final user in response.results) { + print(user.userId); // @alice:server + print(user.displayName); // Alice + print(user.avatarUrl); // mxc://... + + // Avatar als HTTP-URL + final avatarHttp = user.avatarUrl?.getThumbnail( + client, + width: 56, + height: 56, + method: ThumbnailMethod.crop, + ); +} + +print(response.limited); // true wenn weitere Ergebnisse vorhanden + +// MXID direkt laden (ohne Suche) +final profile = await client.getProfileFromUserId('@alice:server'); +print(profile.displayname); +print(profile.avatarUrl); +``` + +--- + +## Öffentliches Raumverzeichnis + +```dart +// Öffentliche Räume (des eigenen Servers) +final result = await client.queryPublicRooms( + limit: 50, +); + +// Mit Suchbegriff +final searched = await client.queryPublicRooms( + limit: 20, + filter: PublicRoomQueryFilter( + genericSearchTerm: 'flutter', + ), +); + +// Anderen Server durchsuchen +final remote = await client.queryPublicRooms( + server: 'matrix.org', + limit: 20, + filter: PublicRoomQueryFilter(genericSearchTerm: 'linux'), +); + +// Paginierung +var batch = await client.queryPublicRooms(limit: 50); +while (batch.nextBatch != null) { + batch = await client.queryPublicRooms( + limit: 50, + since: batch.nextBatch, + ); +} + +// Einzelnes Ergebnis +for (final room in result.chunk) { + print(room.roomId); + print(room.name); + print(room.topic); + print(room.numJoinedMembers); + print(room.worldReadable); + print(room.guestCanJoin); + print(room.avatarUrl); + print(room.canonicalAlias); + print(room.joinRule); // 'public' | 'knock' | etc. + print(room.roomType); // 'm.space' | null +} +``` + +--- + +## Matrix-URI auflösen + +```dart +// Matrix-to URI parsen +// https://matrix.to/#/@alice:server +// https://matrix.to/#/!roomId:server +// https://matrix.to/#/#alias:server +// matrix:u/alice:server +// matrix:r/alias:server +// matrix:roomid/roomId:server + +import 'package:matrix/matrix.dart'; + +final uri = MatrixUri.parse('https://matrix.to/#/#kanal:example.org'); +if (uri != null) { + switch (uri.primaryType) { + case MatrixUriType.room: // Raum-Alias + case MatrixUriType.roomId: // Raum-ID + case MatrixUriType.user: // User-MXID + case MatrixUriType.event: // Event-Link + } + + final identifier = uri.primaryIdentifier; + // z.B. '#kanal:example.org' oder '!roomId:server' + + // Beitreten (bei Raum) + if (uri.primaryType == MatrixUriType.room || + uri.primaryType == MatrixUriType.roomId) { + await client.joinRoomOrAlias( + identifier, + serverName: uri.viaServers, // föderierte Server + ); + } +} + +// Matrix-URI generieren +final roomUri = MatrixUri( + primaryType: MatrixUriType.roomId, + primaryIdentifier: room.id, + viaServers: ['example.org'], +).toUri().toString(); +// → matrix:roomid/roomId:server?via=example.org + +// Matrix.to Link generieren +final matrixToLink = 'https://matrix.to/#/${Uri.encodeComponent(room.canonicalAlias ?? room.id)}'; +``` + +--- + +## Raum per Alias finden + +```dart +// Alias → Raum-ID auflösen +final result = await client.getRoomIdByAlias('#alias:server'); +print(result.roomId); +print(result.servers); // Via-Server für Beitritt + +// Direkt beitreten via Alias +await client.joinRoomOrAlias('#alias:server'); + +// Lokal prüfen (gecacht) +final localRoom = client.rooms.firstWhere( + (r) => r.canonicalAlias == '#alias:server', + orElse: () => throw Exception('Nicht gefunden'), +); +``` diff --git a/docs/matrix-sdk/12-quick-reference.md b/docs/matrix-sdk/12-quick-reference.md new file mode 100644 index 0000000..0657ca6 --- /dev/null +++ b/docs/matrix-sdk/12-quick-reference.md @@ -0,0 +1,366 @@ +# 12 — Schnellreferenz: Typen, Konstanten & Patterns + +--- + +## Wichtige Enum-Werte + +```dart +// Membership +Membership.join // Mitglied +Membership.invite // Eingeladen +Membership.leave // Verlassen +Membership.ban // Gesperrt +Membership.knock // Klopf-Anfrage + +// JoinRules +JoinRules.public // Offen +JoinRules.invite // Nur per Einladung +JoinRules.knock // Beitrittsanfragen erlaubt +JoinRules.restricted // Nur Mitglieder bestimmter Spaces/Räume +JoinRules.knockRestricted + +// HistoryVisibility +HistoryVisibility.worldReadable // Ohne Login lesbar +HistoryVisibility.shared // Alle Mitglieder sehen History +HistoryVisibility.invited // Ab Einladungszeitpunkt +HistoryVisibility.joined // Ab Beitrittszeitpunkt + +// GuestAccess +GuestAccess.canJoin // Gäste erlaubt +GuestAccess.forbidden // Keine Gäste + +// Visibility +Visibility.public // Im Verzeichnis +Visibility.private // Nicht im Verzeichnis + +// CreateRoomPreset +CreateRoomPreset.privateChat // invite + joined History +CreateRoomPreset.publicChat // public + shared History +CreateRoomPreset.trustedPrivateChat // wie private + alle Mitglieder sind Admins + +// EventStatus +EventStatus.sending // Wird übertragen +EventStatus.sent // Server-ACK +EventStatus.synced // Im Sync empfangen +EventStatus.error // Fehlgeschlagen +EventStatus.removed // Gelöscht + +// PresenceType +PresenceType.online +PresenceType.unavailable +PresenceType.offline + +// ThumbnailMethod +ThumbnailMethod.crop // Ausschneiden auf exakte Größe +ThumbnailMethod.scale // Skalieren ohne Abschneiden + +// LoginType +LoginType.mLoginPassword +LoginType.mLoginToken +LoginType.mLoginSso + +// PushRuleKind +PushRuleKind.override +PushRuleKind.content +PushRuleKind.room +PushRuleKind.sender +PushRuleKind.underride +``` + +--- + +## EventTypes Konstanten + +```dart +EventTypes.Message // m.room.message +EventTypes.Sticker // m.sticker +EventTypes.Reaction // m.reaction +EventTypes.RoomRedaction // m.room.redaction +EventTypes.Encrypted // m.room.encrypted +EventTypes.RoomCreate // m.room.create +EventTypes.RoomName // m.room.name +EventTypes.RoomTopic // m.room.topic +EventTypes.RoomAvatar // m.room.avatar +EventTypes.RoomMember // m.room.member +EventTypes.RoomPowerLevels // m.room.power_levels +EventTypes.RoomCanonicalAlias // m.room.canonical_alias +EventTypes.RoomJoinRules // m.room.join_rules +EventTypes.RoomGuestAccess // m.room.guest_access +EventTypes.HistoryVisibility // m.room.history_visibility +EventTypes.RoomPinnedEvents // m.room.pinned_events +EventTypes.RoomTombstone // m.room.tombstone +EventTypes.Encryption // m.room.encryption +EventTypes.SpaceChild // m.space.child +EventTypes.SpaceParent // m.space.parent +EventTypes.CallInvite // m.call.invite +EventTypes.CallAnswer // m.call.answer +EventTypes.CallHangup // m.call.hangup +EventTypes.CallCandidates // m.call.candidates +EventTypes.CallReject // m.call.reject +EventTypes.CallSelectAnswer // m.call.select_answer +EventTypes.CallNegotiate // m.call.negotiate +``` + +--- + +## Häufige Content-Zugriffe + +```dart +// Sicher auf Map-Inhalte zugreifen (Matrix SDK Extension) +final text = event.content.tryGet('body'); +final map = event.content.tryGetMap('info'); +final list = event.content.tryGetList('aliases'); + +// Event-Inhalt strukturiert +final msgtype = event.content['msgtype'] as String?; +final body = event.content['body'] as String?; +final formatted = event.content['formatted_body'] as String?; +final format = event.content['format'] as String?; // 'org.matrix.custom.html' + +// Relation +final relatesTo = event.content + .tryGetMap('m.relates_to'); +final relType = relatesTo?.tryGet('rel_type'); +final eventId = relatesTo?.tryGet('event_id'); + +// Verschlüsselte Medien +final file = event.content.tryGetMap('file'); +final url = file?.tryGet('url'); // mxc:// +final key = file?.tryGetMap('key'); + +// Mentions (MSC3952) +final mentions = event.content.tryGetMap('m.mentions'); +final userIds = mentions?.tryGetList('user_ids') ?? []; +final roomMention = mentions?.tryGet('room') ?? false; +``` + +--- + +## Timeline-Widget Pattern (Flutter) + +```dart +class ChatView extends ConsumerStatefulWidget { + final String roomId; + const ChatView({required this.roomId, super.key}); + @override + ConsumerState createState() => _ChatViewState(); +} + +class _ChatViewState extends ConsumerState { + Timeline? _timeline; + + @override + void initState() { + super.initState(); + _loadTimeline(); + } + + Future _loadTimeline() async { + final client = await ref.read(matrixClientProvider.future); + final room = client.getRoomById(widget.roomId)!; + _timeline = await room.getTimeline( + onUpdate: () => setState(() {}), + ); + setState(() {}); + } + + @override + void dispose() { + _timeline?.cancelSubscriptions(); // WICHTIG! + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final events = _timeline?.events ?? []; + return ListView.builder( + reverse: true, // Neueste unten + itemCount: events.length, + itemBuilder: (ctx, i) { + final event = events[i]; + if (event.type != EventTypes.Message) return const SizedBox(); + return ListTile( + title: Text(event.body), + subtitle: Text(event.senderId), + ); + }, + ); + } +} +``` + +--- + +## Raum-Liste Widget Pattern + +```dart +// StreamProvider für reaktive Raumliste +final roomsProvider = StreamProvider>((ref) async* { + final client = await ref.watch(matrixClientProvider.future); + yield _sortedRooms(client); + await for (final _ in client.onSync.stream) { + yield _sortedRooms(client); + } +}); + +List _sortedRooms(Client client) => client.rooms + .where((r) => r.membership == Membership.join) + .toList() + ..sort((a, b) => + (b.lastEvent?.originServerTs ?? DateTime(0)) + .compareTo(a.lastEvent?.originServerTs ?? DateTime(0))); + +// In Widget: +final rooms = ref.watch(roomsProvider); +rooms.when( + data: (list) => ListView.builder( + itemCount: list.length, + itemBuilder: (ctx, i) { + final room = list[i]; + return ListTile( + leading: CircleAvatar(child: Text(room.name[0])), + title: Text(room.getLocalizedDisplayname()), + subtitle: Text(room.lastEvent?.body ?? ''), + trailing: room.notificationCount > 0 + ? Badge(label: Text('${room.notificationCount}')) + : null, + ); + }, + ), + loading: () => const CircularProgressIndicator(), + error: (e, s) => Text('Fehler: $e'), +); +``` + +--- + +## Nachrichten senden mit optimistischem UI + +```dart +// Das SDK fügt das Event sofort mit Status 'sending' in die Timeline ein. +// UI kann es sofort anzeigen, ohne auf Server zu warten. + +class MessageInput extends ConsumerWidget { + final Room room; + const MessageInput({required this.room, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = TextEditingController(); + return Row( + children: [ + Expanded(child: TextField(controller: controller)), + IconButton( + icon: const Icon(Icons.send), + onPressed: () async { + final text = controller.text.trim(); + if (text.isEmpty) return; + controller.clear(); + try { + await room.sendTextEvent(text); + } catch (e) { + // Event bleibt mit Status 'error' in Timeline + // User kann auf "Erneut senden" tippen + } + }, + ), + ], + ); + } +} +``` + +--- + +## Nützliche Client-Extensions & Helpers + +```dart +// User-Erwähnung formatieren +'@${userId.split(':').first.replaceFirst('@', '')}' // kurzform ohne Server + +// Raum-Display-Name (FluffyChat-Stil) +// Für DMs: Name des Gegenübers +// Für Räume mit Name: Name +// Für Räume ohne Name: kommagetrennte Mitgliedernamen +room.getLocalizedDisplayname() + +// Avatar-URL sicher holen +user.avatarUrl?.getThumbnail(client, width: 56, height: 56) + +// Event-Zeitstempel formatieren +event.originServerTs.toLocal().toString() +// oder mit intl: +DateFormat.Hm().format(event.originServerTs.toLocal()) + +// Stable Notification-ID aus Raum-ID (deterministisch) +int stableId(String roomId) { + var h = 0; + for (final c in roomId.codeUnits) { + h = ((h * 31) + c) & 0x7FFFFFFF; + } + return h == 0 ? 1 : h; +} + +// Matrix-Größenangabe +// event.sizeString → '2.3 MB', '512 KB' + +// mxc:// URI in HTTP umwandeln +mxcUri.getDownloadUri(client) +mxcUri.getThumbnail(client, width: 300, height: 300) +``` + +--- + +## Fehler-Handling + +```dart +// MatrixException — Fehler vom Server +try { + await client.joinRoom('!room:server'); +} on MatrixException catch (e) { + switch (e.error) { + case MatrixError.M_FORBIDDEN: // Kein Zugang + case MatrixError.M_NOT_FOUND: // Raum existiert nicht + case MatrixError.M_LIMIT_EXCEEDED: // Rate-Limit + final retryAfter = e.retryAfterMs; // Wann wieder versuchen + case MatrixError.M_UNKNOWN: + default: + print(e.errorMessage); + } +} + +// TimeoutException — Keine Antwort vom Server +// SocketException — Keine Netzwerkverbindung +// FormatException — Ungültige Server-Antwort + +// Verbindungsstatus überwachen +client.onConnectionStateChanged.stream.listen((state) { + switch (state) { + case ConnectionState.connected: // Sync läuft + case ConnectionState.connecting: // Verbindet + case ConnectionState.reconnecting: // Verbindung wiederhergestellt + } +}); +``` + +--- + +## Wichtige Pakete für Pyramid + +```yaml +dependencies: + matrix: ^6.x.x # Matrix SDK (Haupt-SDK) + flutter_vodozemac: ^x.x.x # E2EE (Rust-basiert, empfohlen) + firebase_messaging: ^x.x.x # FCM Push (Android) + firebase_core: ^x.x.x + flutter_local_notifications: ^x.x.x # Lokale Notifications + flutter_riverpod: ^x.x.x # State Management + sqflite: ^x.x.x # Datenbank + path_provider: ^x.x.x # App-Verzeichnisse + shared_preferences: ^x.x.x # Einfache KV-Speicherung + livekit_client: ^x.x.x # LiveKit für VoIP + flutter_webrtc: ^x.x.x # WebRTC + go_router: ^x.x.x # Navigation + local_notifier: ^x.x.x # Windows Notifications + windows_taskbar: ^x.x.x # Windows Badge +``` diff --git a/docs/matrix-sdk/13-server-admin-continuwuity.md b/docs/matrix-sdk/13-server-admin-continuwuity.md new file mode 100644 index 0000000..88dd78a --- /dev/null +++ b/docs/matrix-sdk/13-server-admin-continuwuity.md @@ -0,0 +1,437 @@ +# 13 — Server-Administration: Continuwuity + +> **Gilt für:** Continuwuity 0.5.x (Fork von Conduwuit, welches ein Fork von Conduit ist) +> **Homeserver:** `steggi-matrix.work` +> **WICHTIG:** Continuwuity hat KEINE Synapse Admin API (`/_synapse/admin/` existiert nicht!) + +--- + +## Inhaltsverzeichnis +- [Server-Admin vs. Room Power Level](#server-admin-vs-room-power-level) +- [Admin Room — Befehle](#admin-room--befehle) +- [Continuwuity Admin HTTP API](#continuwuity-admin-http-api) +- [Power Level in Räumen erzwingen (als Server-Admin)](#power-level-in-räumen-erzwingen) +- [MSC3083: Restricted Join Rules auf Continuwuity](#msc3083-restricted-join-rules-auf-continuwuity) +- [Diagnose: Warum M_FORBIDDEN?](#diagnose-warum-m_forbidden) +- [User-Admin-Status prüfen/setzen](#user-admin-status-prüfensetzen) +- [Raum-Administration via Matrix API](#raum-administration-via-matrix-api) +- [Wichtige Unterschiede: Continuwuity vs. Synapse](#wichtige-unterschiede-continuwuity-vs-synapse) +- [Continuwuity `homeserver.yaml` — relevante Einstellungen](#continuwuity-homeserveryaml--relevante-einstellungen) + +--- + +## Server-Admin vs. Room Power Level + +``` +Continuwuity Server-Admin +├── Ermöglicht: Admin-Room-Befehle, Server-API +├── Ermöglicht: User deaktivieren, Räume löschen (Server-Level) +└── NICHT: Automatisch PL 100 in jedem Matrix-Raum! + +Matrix Room Power Level 100 +├── Gilt pro Raum (in m.room.power_levels State-Event gespeichert) +├── Ermöglicht: Name/Topic/Avatar setzen, Join-Rules ändern, User kicken/bannen +└── Unabhängig vom Server-Admin-Status +``` + +**Beispiel-Problem:** +- Du bist Server-Admin auf `steggi-matrix.work` ✅ +- Du hast PL 100 im Space `!xyz:steggi-matrix.work` ✅ +- Du erstellst einen neuen Channel → Server gibt dir PL 100 als Ersteller ✅ +- Du versuchst `join_rules: restricted` zu setzen → M_FORBIDDEN ❌ + - **Grund:** Continuwuity 0.5.x unterstützt MSC3083 möglicherweise nicht vollständig + +--- + +## Admin Room — Befehle + +Continuwuity hat einen speziellen Admin-Raum. Als Server-Admin kannst du dort Befehle eingeben: + +``` +# Admin-Raum beitreten (normalerweise automatisch eingeladen) +# Raum-Alias: #admins:steggi-matrix.work + +# Befehle im Admin-Raum (als Nachricht senden): + +!admin help # Alle Befehle anzeigen + +!admin make_user_admin @user:server # User zu Server-Admin machen +!admin deactivate_user @user:server # User deaktivieren +!admin list_users # Alle User auflisten + +!admin get_room_state !roomId:server # Room-State anzeigen +!admin delete_room !roomId:server # Raum löschen + +!admin get_mem_usage # Server-Speicherverbrauch +!admin server_version # Server-Version +``` + +```bash +# Als curl (Admin-Token erforderlich): +# Admin-Room-ID herausfinden: +curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3Asteggi-matrix.work" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" +# → {"room_id": "!adminRoomId:steggi-matrix.work", ...} +``` + +--- + +## Continuwuity Admin HTTP API + +```bash +# Basis-URL: https://steggi-matrix.work/_conduwuit/admin/v1/ +# (Nicht /_synapse/admin/ — das ist Synapse-only!) + +# User zu Server-Admin machen +curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ + -H "Content-Type: application/json" \ + -d '{"user_id": "@todesneutron:steggi-matrix.work"}' + +# Server-Status abfragen +curl "https://steggi-matrix.work/_conduwuit/server/version" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" + +# Alternativ — Standard Matrix-Whoami (prüft ob Token gültig): +curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" +# → {"user_id": "@todesneutron:steggi-matrix.work", "is_guest": false} + +# Prüfen ob User Server-Admin ist (Conduwuit-spezifisch): +curl "https://steggi-matrix.work/_conduwuit/admin/v1/users/@todesneutron:steggi-matrix.work" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" +``` + +--- + +## Power Level in Räumen erzwingen (als Server-Admin) + +Als Server-Admin kannst du via Matrix Client API Power Levels in jedem Raum setzen, +**auch wenn du kein Mitglied bist** (Continuwuity erlaubt das für Server-Admins): + +```bash +# Schritt 1: Aktuelles Power-Level-Event lesen +ROOM_ID="!raumId:steggi-matrix.work" +TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" +SERVER="https://steggi-matrix.work" + +curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \ + -H "Authorization: Bearer ${TOKEN}" + +# Schritt 2: Power Level setzen (users-Map ändern) +curl -X PUT "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "users": { + "@todesneutron:steggi-matrix.work": 100 + }, + "users_default": 0, + "events_default": 0, + "state_default": 50, + "ban": 50, + "kick": 50, + "invite": 0, + "redact": 50 + }' +``` + +```dart +// In Flutter / Dart (mit Admin-Token): +// Zuerst aktuellen PL-State lesen: +final currentPl = await client.getRoomStateWithKey( + roomId, + EventTypes.RoomPowerLevels, + '', +); + +// Users-Map erweitern und zurückschreiben: +final users = Map.from( + (currentPl['users'] as Map?)?.cast() ?? {}, +); +users['@todesneutron:steggi-matrix.work'] = 100; + +await client.setRoomStateWithKey( + roomId, + EventTypes.RoomPowerLevels, + '', + { + ...currentPl, + 'users': users, + }, +); +``` + +--- + +## MSC3083: Restricted Join Rules auf Continuwuity + +`join_rules: restricted` (MSC3083) erlaubt Räumen, nur Space-Mitglieder beizutreten. +**Continuwuity 0.5.x unterstützt MSC3083 möglicherweise nicht vollständig.** + +```bash +# Testen ob restricted join rules funktionieren: +ROOM_ID="!testRaum:steggi-matrix.work" +TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" +SPACE_ID="!spaceId:steggi-matrix.work" + +curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "join_rule": "restricted", + "allow": [ + { + "type": "m.room_membership", + "room_id": "'"${SPACE_ID}"'" + } + ] + }' +# → 200 OK: Unterstützt! +# → M_FORBIDDEN oder M_UNRECOGNIZED: Nicht unterstützt oder PL zu niedrig +``` + +**Workaround wenn restricted nicht funktioniert:** +```bash +# Fallback: invite-only (explizit jeden User einladen) +curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"join_rule": "invite"}' +``` + +```dart +// In space_admin_dialog.dart — Fallback wenn restricted nicht supported: +try { + await room.setJoinRules( + JoinRules.restricted, + allow: [ + SpaceRoomJoinRulesAllowInner( + roomId: spaceId, + type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership, + ), + ], + ); +} on MatrixException catch (e) { + if (e.error == MatrixError.M_FORBIDDEN || e.error == MatrixError.M_UNKNOWN) { + // Continuwuity unterstützt restricted join rules nicht + // Fallback zu invite-only: + await room.setJoinRules(JoinRules.invite); + } else { + rethrow; + } +} +``` + +--- + +## Diagnose: Warum M_FORBIDDEN? + +```bash +# 1. Prüfen: Welchen Power Level hat mein User in diesem Raum? +ROOM_ID="!raumId:steggi-matrix.work" +TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" + +curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \ + -H "Authorization: Bearer ${TOKEN}" +# Antwort zeigt 'users' Map — prüfen ob '@todesneutron:steggi-matrix.work' drin ist + +# 2. Prüfen: Was ist state_default? +# Im Antwort-JSON: "state_default": 50 bedeutet: PL 50 nötig für State-Events +# "m.room.join_rules": 100 in events-Map = PL 100 für Join-Rule-Änderung + +# 3. Prüfen: Bin ich Mitglied des Raums? +curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \ + -H "Authorization: Bearer ${TOKEN}" +# → {"membership": "join"} = Mitglied ✅ +# → {"errcode": "M_NOT_FOUND"} = Nicht Mitglied ❌ (Server-Admin kann trotzdem State setzen) + +# 4. Prüfen: Unterstützt der Server restricted join rules? +curl "https://steggi-matrix.work/_matrix/client/v3/capabilities" \ + -H "Authorization: Bearer ${TOKEN}" +# Antwort: capabilities-Objekt — MSC3083 ist nicht explizit gelistet (kein Standard-Capability) +``` + +**Häufige Ursachen und Lösungen:** + +| Fehler | Ursache | Lösung | +|---|---|---| +| M_FORBIDDEN beim State-Event | PL zu niedrig im Raum | Power Level via curl erhöhen (s.o.) | +| M_FORBIDDEN bei join_rules: restricted | MSC3083 nicht unterstützt | Fallback zu `invite` nutzen | +| M_FORBIDDEN beim Channel erstellen | Bug in space_admin_dialog.dart | Raum manuell erstellen, dann Space-Child setzen | +| M_NOT_FOUND | Raum nicht bekannt | Raum beitreten oder Admin-Join | +| M_FORBIDDEN bei Power-Level-Änderung | PL darf nur gleich/höher gesetzt werden | Als Ersteller oder via Admin-API | + +--- + +## User-Admin-Status prüfen/setzen + +```bash +# Prüfen ob User Server-Admin ist: +curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" + +# Auf Pi (SSH): User-Datenbank direkt abfragen (Conduwuit/Continuwuity nutzt RocksDB) +# Alternativ: Über Admin-Room-Befehl +# (Im Admin-Room als Nachricht senden:) +# !admin make_user_admin @todesneutron:steggi-matrix.work + +# Über HTTP (Conduwuit Admin-Endpoint): +curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ + -H "Content-Type: application/json" \ + -d '{"user_id": "@todesneutron:steggi-matrix.work"}' +``` + +--- + +## Raum-Administration via Matrix API + +Da kein Synapse-Admin-API existiert, nutze die Standard-Matrix-Client-API mit Admin-Token: + +```bash +# Raum beitreten (als Admin, auch wenn invite-only): +curl -X POST "https://steggi-matrix.work/_matrix/client/v3/join/!roomId%3Asteggi-matrix.work" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ + -H "Content-Type: application/json" \ + -d '{}' + +# User in Raum einladen: +curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/invite" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ + -H "Content-Type: application/json" \ + -d '{"user_id": "@user:steggi-matrix.work"}' + +# Space-Child-Relation setzen (Channel einem Space hinzufügen): +curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Asteggi-matrix.work/state/m.space.child/!channelId%3Asteggi-matrix.work/" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ + -H "Content-Type: application/json" \ + -d '{ + "via": ["steggi-matrix.work"], + "suggested": false + }' + +# Space-Parent-Relation setzen (im Channel-Raum): +curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Asteggi-matrix.work/state/m.space.parent/!spaceId%3Asteggi-matrix.work/" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ + -H "Content-Type: application/json" \ + -d '{ + "via": ["steggi-matrix.work"], + "canonical": true + }' + +# Alle Mitglieder eines Raums auflisten: +curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/members" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" + +# Raum verlassen (als User, nicht löschen): +curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/leave" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +--- + +## Wichtige Unterschiede: Continuwuity vs. Synapse + +| Feature | Synapse | Continuwuity 0.5.x | +|---|---|---| +| Admin API URL | `/_synapse/admin/v1/` | `/_conduwuit/admin/v1/` | +| Admin Room | Optional | Standard | +| MSC3083 (restricted join rules) | ✅ Vollständig | ⚠️ Teilweise / experimentell | +| Spaces (MSC1772) | ✅ | ✅ Grundfunktionen | +| MatrixRTC / MSC3401 | ✅ (Element Call) | ⚠️ Eingeschränkt | +| Push-Gateway | ✅ | ✅ | +| Server-side search | ✅ | ✅ Grundfunktionen | +| E2EE / Cross-Signing | ✅ | ✅ | +| Presence | ✅ | ✅ | +| Account Deactivation | Admin API | Admin Room / API | +| Room Deletion/Purge | Admin API | Admin Room / API | +| User Listing | Admin API | Admin Room / API | +| Federation | ✅ | ✅ | +| Application Services | ✅ | ⚠️ Eingeschränkt | + +--- + +## Continuwuity `homeserver.yaml` — relevante Einstellungen + +```yaml +# /etc/continuwuity/homeserver.yaml (oder /etc/conduwuit/conduwuit.toml je nach Version) + +# Matrix-RTC / LiveKit +# MSC3266: Room Summary +# MSC3401: MatrixRTC +allow_unstable_room_versions: true + +# Federation (für Multi-Server-Calls nötig) +allow_federation: true +allow_public_room_directory_without_auth: false + +# Push (für Notifications) +# Kein spezieller Config — nutzt Standard-Matrix-Push-API + +# TURN-Server für WebRTC +turn_uris: + - "turn:steggi-matrix.work:3478?transport=udp" + - "turn:steggi-matrix.work:3478?transport=tcp" +turn_secret: "dein-turn-secret" +turn_username: "" # leer = HMAC-basiert +turn_password: "" +turn_ttl: 86400 + +# Registrierung +allow_registration: false # Kein offener Signup +registration_token: "dein-token" # Oder token-basiert + +# Log-Level +log: "warn,conduwuit=info" + +# Performance +rocksdb_parallelism_threads: 4 # Pi 5 hat 4 Cores + +# Unter Continuwuity: Admin-User beim ersten Start setzen +# Erster registrierter User wird automatisch Server-Admin +``` + +```bash +# Auf dem Pi: Config-Datei editieren +ssh steggi@192.168.178.71 +sudo nano /etc/continuwuity/homeserver.yaml +# oder: +sudo nano /etc/conduwuit/conduwuit.toml # Falls TOML-Format + +# Service neu starten: +sudo systemctl restart continuwuity +# oder: +sudo systemctl restart conduwuit + +# Logs prüfen: +sudo journalctl -u continuwuity -f +``` + +--- + +## Schnell-Diagnose Checkliste + +Wenn du M_FORBIDDEN bekommst: + +```bash +# 1. Token gültig? +curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" + +# 2. PL im betroffenen Raum prüfen: +curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.power_levels/" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" + +# 3. Mitglied des Raums? +curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \ + -H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" + +# 4. Was genau schlägt fehl? (Antwort-Body lesen!) +# M_FORBIDDEN + "Power level too low" → PL erhöhen +# M_FORBIDDEN + "Not a member" → Raum beitreten +# M_UNRECOGNIZED → Feature nicht unterstützt +# M_FORBIDDEN + "..." → join_rules restricted Problem +``` diff --git a/docs/matrix-sdk/CHECKLIST.md b/docs/matrix-sdk/CHECKLIST.md new file mode 100644 index 0000000..dc97bcf --- /dev/null +++ b/docs/matrix-sdk/CHECKLIST.md @@ -0,0 +1,433 @@ +# Pyramid — Feature-Checklist & Implementierungsstand + +> Stand: Mai 2026 +> Legende: ✅ Fertig | ⚠️ Teilweise / Bug | ❌ Fehlt | 🔧 In Arbeit + +--- + +## Inhaltsverzeichnis +- [Authentifizierung](#authentifizierung) +- [Raumliste](#raumliste) +- [Chat / Timeline](#chat--timeline) +- [Nachrichtentypen](#nachrichtentypen) +- [Nachrichtenfunktionen](#nachrichtenfunktionen) +- [Direct Messages](#direct-messages) +- [E2EE Verschlüsselung](#e2ee-verschlüsselung) +- [Push Notifications](#push-notifications) +- [VoIP & Calls](#voip--calls) +- [Settings / Einstellungen](#settings--einstellungen) +- [Benutzerprofil](#benutzerprofil) +- [Raum-Einstellungen](#raum-einstellungen) +- [Suche](#suche) +- [Medien](#medien) +- [Read Receipts & Typing](#read-receipts--typing) +- [Spaces](#spaces) +- [Windows / Desktop](#windows--desktop) +- [Sonstige Features](#sonstige-features) +- [Dokumentations-Vollständigkeit](#dokumentations-vollständigkeit) + +--- + +## Authentifizierung +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Login mit Passwort | ✅ | `login_page.dart`, `login_notifier.dart` | +| Homeserver-Auswahl | ✅ | `server_page.dart` — HTTPS-Prefix fix, .well-known Autodiscovery | +| Session wiederherstellen (init) | ✅ | `matrix_client.dart` — `client.init()` beim App-Start | +| Logout | ✅ | Via Matrix SDK `client.logout()` | +| Login-Redirect nach Init | ✅ | `router.dart` — StreamProvider-basierter Guard | +| SSO / OAuth | ❌ | Nicht implementiert | +| Registrierung / Account erstellen | ❌ | Keine UI | +| Login mit Token | ❌ | Nur Passwort-Login | +| Multi-Account | ❌ | Nur ein Account gleichzeitig | +| UIAA Re-Auth (Passwort ändern) | ❌ | Kein UI | + +--- + +## Raumliste +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Raumliste anzeigen | ✅ | `rooms_page.dart`, `room_list_item.dart` | +| Sortierung nach letzter Aktivität | ✅ | In `rooms_provider.dart` | +| Avatar-Anzeige (MXC) | ✅ | `mxc_image.dart` | +| Vorschautext (letztes Event) | ✅ | `room_list_item.dart` | +| Ungelesen-Badge | ✅ | `notificationCount` | +| Ungelesen-Punkt (kein Count) | ✅ | Bei `notificationCount == 0` aber ungelesen | +| Zeitstempel-Formatierung | ✅ | Heute: HH:MM, andere: DD.MM | +| DM-Erkennung (`isDirectChat`) | ✅ | Typ-Differenzierung in Liste | +| Space-Filterung | ✅ | `SpacesRail` filtert nach aktiver Space | +| Einladungen anzeigen | ⚠️ | Membership.invite vorhanden, gesonderte Ansicht fehlt | +| Raum suchen / filtern | ✅ | Via `search_modal.dart` | +| Archivierte Räume | ❌ | Kein UI für left-rooms | +| Raum erstellen (Knopf) | ✅ | `create_join_dialog.dart` | +| Raum beitreten (Alias) | ✅ | `create_join_dialog.dart` | + +--- + +## Chat / Timeline +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Nachrichten anzeigen | ✅ | `message_bubble.dart`, `message_group.dart` | +| Gruppen-Bubbles (gleicher Sender) | ✅ | Continuation-Nachrichten (eingerückt, kein Avatar) | +| Datums-Trenner | ✅ | Tagstrennlinie | +| Ungelesen-Trenner ("Neue Nachrichten") | ✅ | `fullyRead`-Position | +| Scroll-to-Unread beim Öffnen | ✅ | `chat_view.dart` | +| History-Nachladen beim Scrollen | ✅ | `_onScroll()` → `requestHistory(60)` | +| Optimistische Nachrichtenanzeige | ✅ | SDK fügt Event mit Status `sending` ein | +| Nachrichtenstatus (senden/sent) | ✅ | Uhren-Icons | +| Timeline-Cleanup (`cancelSubscriptions`) | ✅ | In `dispose()` | +| Sprung zu bestimmtem Event | ⚠️ | Infrastruktur vorhanden, kein UI | +| Event-Suche in Timeline | ❌ | Nur globale Suche | + +--- + +## Nachrichtentypen +| Typ | Status | Details | +|-----|--------|---------| +| Text (`m.text`) | ✅ | Vollständig | +| Bild (`m.image`) | ✅ | Thumbnail + Vollansicht mit Zoom | +| Video (`m.video`) | ✅ | Playback via `media_kit` | +| Audio (`m.audio`) | ✅ | Player mit Progress/Dauer | +| Datei (`m.file`) | ✅ | Download mit MIME-Icon | +| Sticker (`m.sticker`) | ✅ | Anzeige und Senden (Sticker-Picker) | +| Sprachnachricht (MSC3245) | ⚠️ | Audio-Aufnahme fehlt, Abspielen funktioniert | +| Standort (`m.location`) | ❌ | Nicht implementiert | +| Emote (`m.emote`) | ❌ | Kein gesondertes Rendering | +| Poll (MSC3381) | ❌ | Nicht implementiert | +| Verschlüsselte Nachrichten | ✅ | Automatisch via SDK, Fehler-UI vorhanden | +| Redacted-Events | ⚠️ | Wird angezeigt, kein "Nachricht gelöscht"-Platzhalter | + +--- + +## Nachrichtenfunktionen +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Antworten (Reply) | ✅ | Swipe-Geste + Quote-Anzeige | +| Reaktionen (Emojis) | ✅ | Quick-Picker + vollständiger Emoji-Picker | +| Reaktionen entfernen | ⚠️ | UI zum Entfernen fehlt | +| Nachrichten bearbeiten (Edit) | ⚠️ | `getDisplayEvent()` vorhanden, Senden nicht im UI | +| Nachricht löschen (Redact) | ❌ | Kein Button / Dialog | +| Threads | ❌ | Nicht implementiert | +| Polls | ❌ | Nicht implementiert | +| Weitersenden (Forward) | ❌ | Nicht implementiert | +| Kopieren (Text) | ⚠️ | Nicht explizit sichtbar | +| Long-Press Auswahl-Modus | ✅ | `message_bubble.dart` — Long-Press aktiviert Selection | +| Bulk-Aktionen (Auswahl) | ⚠️ | Modus vorhanden, Aktionen begrenzt | +| Pinned Messages Panel | ✅ | `pinned_messages_panel.dart` | +| Pins setzen (UI-Aktion) | ❌ | Kein Pin-Button in Nachricht | +| Linkvorschau | ❌ | Keine URL-Preview | +| Mention-Links klickbar | ✅ | `@user`-Links via `url_launcher` | + +--- + +## Direct Messages +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Neuen DM starten | ✅ | `new_dm_dialog.dart` → `startDirectChat()` | +| User-Verzeichnis-Suche | ✅ | `searchUserDirectory()` | +| DM-Erkennung in Raumliste | ✅ | `room.isDirectChat` | +| Partner-Avatar/Name | ✅ | `directChatMatrixID` → Profil laden | +| DM-Verschlüsselung | ✅ | Standard E2EE bei DM-Erstellung | +| Gruppen-DM | ⚠️ | Erstellbar, kein gesondertes UI | +| DM vergessen / verlassen | ⚠️ | Kein UI-Button | +| DM-spezifische Notification-Sounds | ❌ | Nicht implementiert | + +--- + +## E2EE Verschlüsselung +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Vodozemac Rust-Crypto | ✅ | `flutter_vodozemac` initialisiert in `matrix_client.dart` | +| E2EE in verschlüsselten Räumen | ✅ | Automatisch via SDK | +| Bootstrap-Flow (SSSS + Cross-Signing) | ✅ | `bootstrap_dialog.dart` — vollständige State-Machine | +| Cross-Signing Setup | ✅ | Master-, Self- und User-Signing-Key Erstellung | +| Key Backup (online) | ✅ | SSSS-gesichertes Backup | +| Schlüssel-Verifikation (SAS/Emoji) | ✅ | Via Bootstrap-Flow | +| QR-Code-Verifikation | ⚠️ | SDK-Support vorhanden, kein UI | +| Device-Liste anzeigen | ❌ | Kein UI | +| Gerät sperren/blockieren | ❌ | Kein UI | +| Fehlende Schlüssel anfordern | ✅ | `requestMissingSessionKeys()` bei Bad-Encrypted | +| Entschlüsselungs-Fehler-UI | ✅ | "Nachricht konnte nicht entschlüsselt werden" + Schlüssel-Button | +| Schlüssel importieren/exportieren | ❌ | Kein UI (Infrastruktur vorhanden) | + +--- + +## Push Notifications +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| FCM Token Registrierung | ✅ | `fcm_push_service.dart` | +| Pusher auf Homeserver | ✅ | `postPusher()` mit Sygnal-Gateway | +| Token-Refresh Handling | ✅ | `onTokenRefresh.listen()` | +| PushService.kt (native FCM) | ✅ | Läuft auch bei geschlossener App | +| Benachrichtigung bei geschlossener App | 🔧 | APK neu gebaut, noch nicht getestet | +| Absender-Name in Notification | ✅ | HTTP-Fetch von Matrix API in PushService.kt | +| Verschlüsselte Nachricht entschlüsseln | ⚠️ | "Neue Nachricht" bei geschlossener App; `decryptAndUpdateNotification` bei Hintergrund-App | +| Inline Reply in Notification | 🔧 | ReplyReceiver auf getActivity umgestellt — APK gebaut, ungetestet | +| Antwort wirklich abschicken | 🔧 | `replyFromNotification` MethodChannel — APK gebaut, ungetestet | +| Gelesen-Button in Notification | ✅ | `ACTION_DISMISS` über `ReplyReceiver.kt` | +| Notification bei Hintergrund-App | ✅ | Heartbeat-Check verhindert Duplikate | +| Notification-Kanal Android | ✅ | `pyramid_messages`, HIGH Importance | +| Stabile Notification-ID | ✅ | `stableId(roomId)` — gleicher Algo in Kotlin + Dart | +| Windows Notification | ✅ | `local_notifier` (Windows Toast) | +| Windows Taskbar Flash | ✅ | `WindowsTaskbar.setProgressMode()` | +| Ausstehende Reply beim App-Öffnen senden | ✅ | `_sendPendingReply()` bei Resume + Sync | +| Push Rules konfigurieren | ❌ | Kein UI | +| Raum stumm schalten | ❌ | Kein UI | +| Nur-Erwähnung-Modus | ✅ | `notifMentionOnlyDmProvider` — Preferences | +| iOS Push | ❌ | Kein iOS-Build | +| Web Push | ❌ | Kein Web-Build | + +--- + +## VoIP & Calls +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Matrix WebRTC Legacy-Calls | ✅ | `voip_manager.dart` — CallSession | +| LiveKit SFU-Calls | ✅ | `livekit_call_manager.dart` | +| Unified Call-UI | ✅ | `voice_channel.dart` | +| Audio-Call | ✅ | Mikrofon + Speaker | +| Video-Call | ✅ | Kamera + Remote-Video | +| Screen-Sharing | ✅ | `screen_share_picker.dart` | +| Video-Qualitätsstufen | ✅ | SD/HD/FHD/4K Presets | +| Mute-Toggle | ✅ | `setMicrophoneEnabled()` | +| Deafen-Toggle | ✅ | Audio komplett stumm | +| Kamera-Toggle | ✅ | `setCameraEnabled()` | +| Kamera-Wechsel (Front/Rück) | ⚠️ | SDK-Support vorhanden | +| Teilnehmer-Grid | ✅ | Mehrere Participants anzeigen | +| Lokale Video-Vorschau | ✅ | Overlay-Widget | +| Klingel-System (to-device) | ⚠️ | Infrastruktur vorhanden, Klingelton fehlt | +| Incoming-Call-UI | ⚠️ | Kein dediziertes Klingel-Screen | +| Call-History | ❌ | Nicht implementiert | +| TURN-Server via Matrix | ⚠️ | `getTurnServer()` — ob konfiguriert unklar | +| Hintergrund-Foreground-Service | ✅ | `FlutterForegroundService` via webrtc | +| MatrixRTC Membership-Events | ✅ | `io.pyramid.voice.presence` State-Events | +| LiveKit-Token-Exchange | ✅ | `livekit_token.dart` | + +--- + +## Settings / Einstellungen +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Settings-Seite (Hauptseite) | ⚠️ | `settings_page.dart` — NUR PLATZHALTER | +| Settings Modal | ✅ | `settings_modal.dart` — vollständiger Modal | +| Dark/Light Mode | ✅ | `themeModeProvider` + Toggle | +| Akzentfarbe (5 Presets) | ✅ | Amber/Coral/Mint/Violet/Cobalt/Lime | +| Eckenradius Slider | ✅ | `radiusProvider` | +| UI-Motion Toggle | ✅ | `motionProvider` | +| UI-Density Toggle | ✅ | `densityProvider` | +| Desktop Notifications | ✅ | `notifDesktopProvider` | +| Notification Sound | ✅ | `notifSoundProvider` | +| Nachrichtenvorschau | ✅ | `notifPreviewProvider` | +| Nur-Erwähnung-Modus | ✅ | `notifMentionOnlyDmProvider` | +| Presence anzeigen (Online) | ✅ | `privacyPresenceProvider` | +| Read Receipts senden | ✅ | `privacyReadReceiptsProvider` | +| Typing-Indikator senden | ✅ | `privacyTypingProvider` | +| Profil bearbeiten (Name/Avatar) | ❌ | Kein UI | +| Geräte-Verwaltung | ❌ | Kein UI | +| Session-Liste | ❌ | Kein UI | +| Passwort ändern | ❌ | Kein UI | +| Account löschen | ❌ | Kein UI | +| Raumspezifische Benachrichtigungen | ❌ | Kein UI | +| Sprache / Lokalisierung | ❌ | Nur Systemsprache | +| Barrierefreiheit | ❌ | Keine Einstellungen | + +--- + +## Benutzerprofil +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Avatar anzeigen (MXC) | ✅ | `mxc_image.dart` überall | +| Displayname auflösen | ✅ | Fallback auf `@username` | +| Online-Präsenz senden | ✅ | `setPresence(online)` alle 10s in `app_shell.dart` | +| Präsenz-Anzeige anderer User | ⚠️ | Infrastruktur, kein UI | +| Profil-Popover (Hover-Karte) | ✅ | `profile_popover.dart` | +| Eigenes Profil bearbeiten | ❌ | Kein UI | +| Avatar hochladen | ❌ | Kein UI | +| Status-Nachricht | ❌ | Nicht implementiert | +| User ignorieren / blockieren | ❌ | Kein UI | +| User melden | ❌ | Kein UI | + +--- + +## Raum-Einstellungen +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Space-Admin Panel (Name/Avatar/Banner) | ✅ | `space_admin_dialog.dart` — Overview-Tab | +| Power-Level-Editor (Mitglieder) | ✅ | `space_admin_dialog.dart` — Members-Tab | +| Permissions-Tab (Raum-PLs) | ✅ | `space_admin_dialog.dart` — Permissions-Tab | +| Kind-Räume verwalten | ✅ | `space_admin_dialog.dart` — Channels-Tab | +| Raum-Name ändern (normaler Raum) | ❌ | Kein UI | +| Raum-Thema ändern | ❌ | Kein UI | +| Raum-Avatar ändern | ❌ | Kein UI | +| Join-Rules ändern | ❌ | Kein UI | +| History-Visibility ändern | ❌ | Kein UI | +| E2EE aktivieren | ❌ | Kein UI | +| Raum-Alias verwalten | ❌ | Kein UI | +| Raum verlassen (Button) | ❌ | Kein UI | +| Raum vergessen (forget) | ❌ | Kein UI | +| User einladen (aus Chat) | ❌ | Kein UI | +| Kick / Ban (Moderation) | ❌ | Kein UI | + +--- + +## Suche +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Server-seitige Nachrichtensuche | ✅ | `search_modal.dart` → `/search` API | +| Lokale Raum-/DM-Suche | ✅ | Filter auf `client.rooms` | +| Personen-Suche | ✅ | Lokale DM-Partner-Suche | +| Medien-Filter (Bilder/Videos/Audio) | ✅ | `search_modal.dart` | +| Paginierung (nextBatch) | ✅ | Infinite scroll | +| Suchergebnis-Highlighting | ✅ | Suchbegriff hervorheben | +| Debounced Query (400ms) | ✅ | `debounce` in Suchfeld | +| Öffentliches Raumverzeichnis | ❌ | Kein UI zum Entdecken | +| User-Verzeichnis-Suche (global) | ✅ | In `new_dm_dialog.dart` | + +--- + +## Medien +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Bild senden | ✅ | `MatrixImageFile` + Upload | +| Bild anzeigen (Thumbnail + Vollbild) | ✅ | `MediaViewer` mit Zoom/Pan | +| Video senden | ✅ | | +| Video abspielen | ✅ | `media_kit` | +| Audio senden | ✅ | | +| Audio abspielen | ✅ | Player mit Progress | +| Datei senden | ✅ | `MatrixFile` | +| Datei herunterladen | ✅ | MIME-Icon + Browser-Öffner | +| Clipboard-Bild einfügen (Windows) | ✅ | FFI + `clipboard_image.dart` | +| Drag & Drop Upload (Desktop) | ✅ | `desktop_drop` | +| GIF-Picker (Giphy) | ✅ | Trending + Suche + Favoriten | +| Sticker-Picker | ✅ | Pack-Browser | +| Bild-Komprimierung | ✅ | `shrinkImageMaxDimension` | +| Verschlüsselte Medien | ✅ | Automatisch bei E2EE-Räumen | +| Sprachnachricht aufnehmen | ❌ | Kein Recorder | +| Kamera direkt (in App) | ❌ | Nur Gallery-Picker | +| Dokument-Vorschau | ❌ | Kein PDF-Viewer | +| Media-Galerie (Raum-Mediathek) | ❌ | Kein UI | + +--- + +## Read Receipts & Typing +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Read Receipts senden | ✅ | `setReadMarker()` (privacy-gesteuert) | +| Eigene ✓✓ Bestätigung | ✅ | Anzeige auf eigenen Nachrichten | +| Read-Marker anderer anzeigen | ⚠️ | Position-Marker vorhanden, kein Avatar-Stack | +| Typing-Indikator senden | ✅ | 7s Auto-Timeout | +| Typing-Indikator empfangen | ✅ | Anzeige-Platzhalter | +| Typing-User-Namen anzeigen | ⚠️ | Platzhalter, keine Namen | + +--- + +## Spaces +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Space-Erkennung (`isSpace`) | ✅ | | +| Spaces-Rail (Sidebar) | ✅ | `spaces_rail.dart` | +| Space-Filterung der Raumliste | ✅ | Nur Räume der aktiven Space | +| Space-Admin Panel | ✅ | `space_admin_dialog.dart` | +| Space erstellen | ✅ | `create_join_dialog.dart` | +| Kind-Räume hinzufügen | ✅ | In Space-Admin | +| Kind-Räume entfernen | ✅ | In Space-Admin | +| Voice-Präsenz in Space | ✅ | `io.pyramid.voice.presence` Events | +| Space-Banner | ✅ | Mit Crop-Dialog | +| Verschachtelte Spaces | ⚠️ | Anzeige, kein eigener Sub-Space-Rail | +| Space beitreten (öffentlich) | ⚠️ | Technisch möglich, kein Discovery-UI | + +--- + +## Windows / Desktop +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Windows Build | ✅ | CMake + MSIX | +| Windows Notifications | ✅ | `local_notifier` | +| Taskbar Flash | ✅ | `WindowsTaskbar` | +| Update-Checker (Gitea API) | ✅ | `update_checker.dart` — 4h Intervall | +| Update-Banner | ✅ | `update_banner.dart` | +| Update-Download | ✅ | `update_download_dialog.dart` | +| APK Auto-Install (Android) | ✅ | Kotlin MethodChannel + FileProvider | +| PC Update (exe/msix) Download | ⚠️ | Release-Script pusht .zip statt .exe → öffnet Git-Seite | +| Drag & Drop | ✅ | `desktop_drop` | +| Clipboard Paste (Bild) | ✅ | FFI Windows API | +| Resizable Sidebar | ✅ | `panel_layout.dart` — Drag-Divider | +| Keyboard Shortcuts | ⚠️ | Enter zum Senden, Ctrl+V — kein vollständiges System | +| System Tray | ❌ | Nicht implementiert | +| Fenster-Badge (ungelesen) | ⚠️ | Nur Taskbar-Flash, kein Zähler | +| macOS / Linux | ❌ | Nicht gebaut/getestet | + +--- + +## Sonstige Features +| Feature | Status | Details / Datei | +|---------|--------|-----------------| +| Emoji-Picker | ✅ | Kategorisiert + Suche | +| Quick-Reaktionen (6 Emojis) | ✅ | Hover-Aktionsleiste | +| Mitglieder-Panel (Raum) | ✅ | `members/` — Teilnehmerliste | +| Profil-Popover (Hover) | ✅ | `profile_popover.dart` | +| Neuer-DM Dialog | ✅ | `new_dm_dialog.dart` | +| Raum-erstellen Dialog | ✅ | `create_join_dialog.dart` | +| Nachricht-Auswahl-Modus | ✅ | Long-Press | +| Reply-Swipe-Geste | ✅ | Rechts-wischen zum Antworten | +| Immersive-Mode (Android) | ✅ | Status-Bar ausgeblendet | +| PyramidLoader (Ladeanimation) | ✅ | `pyramid_loader.dart` | +| Rich-Text-Editor | ❌ | Kein Markdown/Formatting-UI | +| HTML-Nachrichtenrendering | ❌ | Kein `flutter_html` | +| Message Forwarding | ❌ | | +| Nachrichtenübersetzung | ❌ | | +| Bot-Kommandos (`/`) | ❌ | | +| Matrix-URI Handler | ❌ | Kein Deep-Link-Handler | +| Barcode/QR-Scanner | ⚠️ | Nur in Verifikation | + +--- + +## Dokumentations-Vollständigkeit + +| Doku-Abschnitt | Vollständig | Anmerkungen | +|----------------|-------------|-------------| +| 01 Auth | ✅ | SSO, UIAA, alle Login-Flows | +| 02 Räume | ✅ | Create, Join, Spaces, Power Levels | +| 03 Nachrichten | ✅ | Text, Replies, Edits, Threads, Polls | +| 04 DMs | ✅ | Alle DM-Flows | +| 05 Medien | ✅ | Upload, Download, alle Dateitypen | +| 06 E2EE | ✅ | Setup, SAS, Cross-Signing, Key Backup | +| 07 Push | ✅ | FCM, Push Rules, Pyramid-Architektur | +| 08 VoIP | ✅ | Legacy m.call, MatrixRTC | +| 08b LiveKit | ✅ | **KOMPLETT** — Server-Setup bis Flutter-UI | +| 09 Profil | ✅ | Presence, Account-Data, Tags | +| 10 Room-Admin | ✅ | Power Levels, Moderation, ACL, Synapse Admin | +| 11 Suche | ✅ | Event-Suche, User-Suche, Raumverzeichnis | +| 12 Schnellreferenz | ✅ | Alle Enums, Widget-Patterns, Fehlerhandling | + +--- + +## Prioritäts-Roadmap + +### 🔴 Kritisch (bald testen/fixen) +1. **Notification-Reply testen** — Neue APK (getActivity-Fix) installieren und Reply-Flow verifizieren +2. **Benachrichtigung bei geschlossener App** — Mit neuer APK testen (PushService.kt Ansatz) +3. **PC Update-Script** — `.zip` durch `.exe`-Installer ersetzen in `scripts/release.ps1` + +### 🟠 Wichtig (nächste Features) +4. **Nachricht löschen (Redact)** — Long-Press-Menü → `event.redactEvent()` +5. **Nachricht bearbeiten** — Edit-Modus im Composer → `sendTextEvent(editEventId:...)` +6. **Raum-Einstellungen** — Name/Thema/Avatar für normale Räume (`room.setName()` etc.) +7. **Profil bearbeiten** — Displayname + Avatar in Settings +8. **Registrierung** — Account-Erstellung im Onboarding + +### 🟡 Sinnvoll (mittelfristig) +9. **Einladungen-Screen** — Pending invites sichtbar darstellen +10. **Kick/Ban UI** — Moderation aus der Mitgliederliste +11. **Device-Verwaltung** — Eigene Sessions anzeigen + löschen +12. **Rich-Text-Editor** — Markdown/Formatierung in Nachrichten +13. **Klingelton bei eingehendem Call** — Audio + dedizierter Klingel-Screen +14. **Lautlos/Stumm per Raum** — Push-Rules im UI + +### 🟢 Nice-to-have +15. Sprachnachrichten aufnehmen +16. HTML-Nachrichtenrendering (`flutter_html`) +17. Matrix-URI Deep-Links +18. Media-Galerie pro Raum +19. System Tray (Windows) +20. Polls / Abstimmungen diff --git a/docs/matrix-sdk/README.md b/docs/matrix-sdk/README.md new file mode 100644 index 0000000..d94cca0 --- /dev/null +++ b/docs/matrix-sdk/README.md @@ -0,0 +1,122 @@ +# Matrix SDK — Pyramid Entwicklungsreferenz + +> **Basis:** [`package:matrix`](https://pub.dev/packages/matrix) (Dart/Flutter Matrix SDK) +> **Ziel:** Vollständige Landkarte aller Matrix-Funktionen mit Code-Snippets für den Pyramid-App-Bau. +> **Referenz-Implementierung:** FluffyChat (https://gitlab.com/famedly/fluffychat) + +--- + +## Navigation + +| # | Thema | Beschreibung | +|---|-------|--------------| +| [01](01-auth.md) | **Authentifizierung** | Login, Logout, Register, SSO, Token-Management | +| [02](02-rooms.md) | **Räume & Spaces** | Erstellen, Beitreten, Verlassen, Spaces, Suche | +| [03](03-messages.md) | **Nachrichten & Events** | Senden, Timeline, Reaktionen, Edits, Threads, Polls | +| [04](04-dm.md) | **Direct Messages** | DM-Räume finden, starten, verwalten | +| [05](05-media.md) | **Medien & Dateien** | Upload, Download, Thumbnails, verschlüsselte Medien | +| [06](06-encryption.md) | **E2EE Verschlüsselung** | Keys, Verifizierung, Cross-Signing, Key-Backup | +| [07](07-push.md) | **Push & Notifications** | Pusher, Push Rules, FCM, lokale Benachrichtigungen | +| [08](08-voip.md) | **VoIP & Calls** | MatrixRTC, Legacy m.call, Klingel-Flow | +| [08b](08b-livekit-streaming.md) | **LiveKit Streaming** ⭐ | **Vollständige Server-Anleitung + Flutter-Implementierung** | +| [09](09-profile.md) | **Profil & Präsenz** | Avatar, Displayname, Presence, Account-Daten | +| [10](10-room-admin.md) | **Raum-Administration** | Power Levels, Moderierung, Einstellungen, Bans | +| [11](11-search.md) | **Suche & Discovery** | Nachrichten-Suche, User-Suche, Raum-Verzeichnis | +| [12](12-quick-reference.md) | **Schnellreferenz** | Alle Enums, Widget-Patterns, Fehlerhandling | +| [13](13-server-admin-continuwuity.md) | **Server-Admin Continuwuity** ⭐ | Admin API, Power Levels erzwingen, MSC3083, Diagnose | +| [CHECKLIST](CHECKLIST.md) | **Feature-Checklist** ⭐ | Implementierungsstand + Roadmap | + +--- + +## Schnellreferenz: Client-Initialisierung + +```dart +import 'package:matrix/matrix.dart'; + +final client = Client( + 'MeinApp', // App-Name (User-Agent) + database: MatrixSdkDatabase(...), // Persistenz (sqflite) + nativeImplementations: NativeImplementationsIsolate(compute), // E2EE + logLevel: Level.warning, +); + +// Initialisieren (lädt gespeicherten Session-State) +await client.init( + waitForFirstSync: false, // UI nicht blockieren + waitUntilLoadCompletedLoaded: false, +); +``` + +--- + +## Architektur-Überblick + +``` +Client +├── rooms: List // alle bekannten Räume +├── userID: String? // eigene MXID (@user:server) +├── accessToken: String? // Bearer-Token +├── homeserver: Uri // https://matrix.example.org +│ +├── Room +│ ├── id: String // !roomId:server +│ ├── getTimeline() // Event-Stream +│ ├── sendTextEvent() +│ ├── sendEvent() +│ ├── members // Map +│ └── states // Map> +│ +├── Event +│ ├── type: String // m.room.message, m.reaction, … +│ ├── content: Map +│ ├── body: String // text body (decrypted) +│ └── messageType: String? // m.text, m.image, m.file, … +│ +└── User + ├── id: String // MXID + ├── displayName: String? + └── avatarUrl: Uri? +``` + +--- + +## Wichtige Streams / Provider-Hooks + +```dart +// Auf Sync-Events reagieren +client.onSync.stream.listen((SyncUpdate update) { ... }); + +// Neue/geänderte Räume +client.onRoomUpdate.stream.listen((String roomId) { ... }); + +// Verbindungsstatus +client.onConnectionStateChanged.stream.listen((ConnectionState s) { ... }); + +// Eigener User geändert +client.onUserEvent.stream.listen((EventUpdate update) { ... }); + +// Benachrichtigungs-Events (gefiltert nach Push-Rules) +client.onNotification.stream.listen((Event event) { ... }); +``` + +--- + +## Tipps für Riverpod-Integration + +```dart +// Matrix-Client als Provider +final matrixClientProvider = FutureProvider((ref) async { + final client = Client('Pyramid', ...); + await client.init(waitForFirstSync: false); + return client; +}); + +// Raumliste reaktiv +final roomsProvider = StreamProvider>((ref) async* { + final client = await ref.watch(matrixClientProvider.future); + yield client.rooms; + await for (final _ in client.onSync.stream) { + yield client.rooms; + } +}); +``` diff --git a/docs/server-incident-2026-05-31.md b/docs/server-incident-2026-05-31.md new file mode 100644 index 0000000..e41a874 --- /dev/null +++ b/docs/server-incident-2026-05-31.md @@ -0,0 +1,87 @@ +# Server-Incident: Messaging-Ausfall — 2026-05-31 + +## Betroffene Nutzer +`@uta:steggi-matrix.work`, `@circuitslave:steggi-matrix.work` + +## Symptome +- Uta und Circuitslave konnten in verschlüsselten DM-Räumen keine Nachrichten senden +- Nachrichten von `@todesneutron` kamen nicht an bzw. konnten nicht entschlüsselt werden +- `@jochen` (Test-Account, FluffyChat) war nicht betroffen +- Später: Element zeigt „Verbindung unterbrochen / Server offline" +- Nach Behebung: alle Chats zunächst leer (History nicht sichtbar) + +--- + +## Ursachen (in Reihenfolge des Auftretens) + +### 1. Continuwuity 0.5.8 — `room_keys/version` 500-Fehler +**Endpunkt:** `GET /_matrix/client/unstable/room_keys/version` + +Continuwuity 0.5.8 crashte bei dieser Anfrage mit: +``` +response error: JSON serialization failed: missing field `algorithm` at line 1 column 2 +500 Internal Server Error +``` + +Element prüft diesen Endpunkt beim Start, bevor es verschlüsselte Nachrichten sendet. +Der 500-Fehler verhinderte den korrekten Aufbau des E2E-Zustands → Senden blockiert. + +FluffyChat/Pyramid implementieren diesen Check nicht → waren nicht betroffen. + +### 2. Zu viele unverifizierte Geräte bei `@todesneutron` +Durch die Pyramid-Entwicklung wurden 14 Geräte angesammelt (alte FluffyChat-, +Commet- und Pyramid-Debug-Sessions). Element von Uta/Circuitslave sah all diese +Geräte als unbekannt und konnte nicht für alle gleichzeitig verschlüsseln. + +### 3. Continuwuity 0.5.9-latest — Axum Routing Panic (Folgeschaden) +Beim Update auf `latest` (0.5.9, Commit `0514491`) trat ein Panic auf: +``` +thread 'conduwuit:worker' panicked at axum-0.8.9/src/routing/path_router.rs:406:22: +no route for id. This is a bug in axum. +``` + +Ausgelöst durch: `GET /_matrix/client/v3/user/@circuitslave:steggi-matrix.work/filter/ieOT` + +Circuitslaves Element hatte nach dem ersten Neustart seinen Filter verloren und +sendete diese Anfrage in einer Schleife → Server-Worker crashte wiederholt → +Cloudflare bekam EOF → alle Clients offline. + +### 4. Leere Chat-History nach Behebung +Continuwuity 0.5.9 hat die interne RocksDB-Struktur von 98 auf 100 Column Families +erweitert. Das invalidierte alle gespeicherten Sync-Token der Clients → Fresh Initial +Sync → Server schickt nur aktuelle Room-States ohne vollständige Timeline. + +**Die Daten waren nicht verloren** — Pagination via `/messages` funktionierte korrekt. +Durch Hochscrollen in den Räumen wurde die History nachgeladen. + +--- + +## Maßnahmen + +| Schritt | Aktion | +|---|---| +| Diagnose | Geräteliste, Raumzustand, Server-Logs via SSH geprüft | +| Geräte aufräumen | 14 → 5 aktive Geräte bei `@todesneutron` | +| Server-Update | Wechsel auf `continuwuity:main` (Commit `1faa09b`) | +| Ergebnis | `room_keys/version` gibt gültige Antwort, Filter-Panic behoben | +| History | Nutzer scrollten in Räumen nach oben → Pagination lud History nach | + +--- + +## Server-Infrastruktur + +- **Homeserver:** Continuwuity (Conduit-Fork, Rust) via Docker +- **Compose-Datei:** `/home/steggi/matrix/docker-compose.yml` +- **Image (vorher):** `registry.gitlab.com/continuwuity/continuwuity:latest` (0.5.8 → 0.5.9) +- **Image (jetzt):** `registry.gitlab.com/continuwuity/continuwuity:main` (0.5.9, Commit `1faa09b`) +- **Tunnel:** Cloudflare (`cloudflared`) +- **Datenbank:** RocksDB unter `/home/steggi/matrix/db/` + +--- + +## Lektionen + +- `latest`-Tag vor produktivem Update testen (0.5.9-latest hatte Routing-Bug) +- Alte Geräte regelmäßig aus Element/Pyramid abmelden (verhindert E2E-Probleme) +- `continuwuity:main` ist stabiler als `continuwuity:latest` +- FluffyChat/Pyramid senden auch ohne Key-Backup-Check → können als Fallback dienen diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml new file mode 100644 index 0000000..a60276b --- /dev/null +++ b/flutter_launcher_icons.yaml @@ -0,0 +1,12 @@ +flutter_launcher_icons: + android: true + ios: true + macos: + generate: true + image_path: "assets/logo.png" + windows: + generate: true + image_path: "assets/logo.png" + image_path: "assets/logo.png" + adaptive_icon_background: "#1A1B1E" + adaptive_icon_foreground: "android/app/src/main/res/drawable/ic_launcher_foreground.xml" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c0fdae0..32ea1d6 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -431,7 +431,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -488,7 +488,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..ad5d766 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..5c82cc3 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..eca545c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..678e0a1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..70ebb59 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..1e106b3 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..7b5fc96 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..eca545c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..331911b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..6e2ce81 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..5ca5ac5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..f1fbe63 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..82b0fa2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..6d129dc Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..6e2ce81 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..40f9cac 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..aa3bda2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..22ecbf5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..9803d95 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..42ad51d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..35e486e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/core/app.dart b/lib/core/app.dart index d7fa68c..df12de8 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -1,29 +1,107 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground; import 'package:pyramid/core/router.dart'; +import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/features/auth/login_notifier.dart'; +import 'package:pyramid/widgets/pyramid_loader.dart'; -class PyramidApp extends ConsumerWidget { +class PyramidApp extends ConsumerStatefulWidget { const PyramidApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final router = ref.watch(routerProvider); - final colorScheme = ref.watch(colorSchemeProvider); + ConsumerState createState() => _PyramidAppState(); +} - return MaterialApp.router( - title: 'Pyramid', - theme: buildTheme(colorScheme, Brightness.light), - darkTheme: buildTheme(colorScheme, Brightness.dark), - themeMode: ThemeMode.system, - routerConfig: router, - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: const [Locale('de'), Locale('en')], +class _PyramidAppState extends ConsumerState + with WidgetsBindingObserver { + bool _prefsLoaded = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _loadPersistedPrefs(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + setAppForeground(true); + checkPendingReply(ref); + case AppLifecycleState.paused: + case AppLifecycleState.detached: + // Clears heartbeat so PushService.kt shows FCM notifications immediately. + // Background Matrix syncs must not re-set the heartbeat (see notification_service.dart). + setAppForeground(false); + default: + break; + } + } + + Future _loadPersistedPrefs() async { + final saved = await loadThemePrefs(); + if (!mounted) return; + ref.read(themeModeProvider.notifier).state = saved.isDark; + ref.read(accentProvider.notifier).state = saved.accentIdx; + ref.read(radiusProvider.notifier).state = saved.radius; + ref.read(motionProvider.notifier).state = saved.motion; + setState(() => _prefsLoaded = true); + } + + @override + Widget build(BuildContext context) { + // Block the router from rendering until auth state is known so the + // /server page never flashes before the redirect to /app fires. + final authAsync = ref.watch(isLoggedInProvider); + if (authAsync.isLoading || !_prefsLoaded) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: Color(0xFF1A1B1E), + body: Center(child: PyramidLoader(size: 80)), + ), + ); + } + + final router = ref.watch(routerProvider); + final isDark = ref.watch(themeModeProvider); + final accentIdx = ref.watch(accentProvider); + final radius = ref.watch(radiusProvider); + final density = ref.watch(densityProvider); + final motion = ref.watch(motionProvider); + final accent = accentPresets[accentIdx].color; + + return PyramidTheme( + isDark: isDark, + accent: accent, + radius: radius, + density: density, + motion: motion, + child: MaterialApp.router( + title: 'Pyramid', + theme: buildPyramidThemeData(false), + darkTheme: buildPyramidThemeData(true), + themeMode: isDark ? ThemeMode.dark : ThemeMode.light, + routerConfig: router, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('de'), Locale('en')], + debugShowCheckedModeBanner: false, + ), ); } } diff --git a/lib/core/app_state.dart b/lib/core/app_state.dart new file mode 100644 index 0000000..fbf3bd6 --- /dev/null +++ b/lib/core/app_state.dart @@ -0,0 +1,207 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/settings_prefs.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/livekit_call_manager.dart'; +import 'package:pyramid/core/voip_manager.dart'; +import 'package:pyramid/features/rooms/rooms_provider.dart'; + +// ─── Theme state ────────────────────────────────────────────────────────────── + +final themeModeProvider = StateProvider((ref) => true); // true = dark +final accentProvider = StateProvider((ref) => 0); // index into accent presets + +const accentPresets = [ + (name: 'amber', color: PyramidColors.accentAmber), + (name: 'coral', color: PyramidColors.accentCoral), + (name: 'mint', color: PyramidColors.accentMint), + (name: 'violet', color: PyramidColors.accentViolet), + (name: 'cobalt', color: PyramidColors.accentCobalt), + (name: 'lime', color: PyramidColors.accentLime), +]; + +final radiusProvider = StateProvider((ref) => 12); +final densityProvider = StateProvider((ref) => 1); +final motionProvider = StateProvider((ref) => 1); + +// ─── Shell layout state ─────────────────────────────────────────────────────── + +final railCollapsedProvider = StateProvider((ref) => false); + +// True while the app window is the foreground / focused window. +// Set to false when the lifecycle transitions to inactive/paused/detached. +final windowFocusedProvider = StateProvider((ref) => true); +final membersPanelOpenProvider = StateProvider((ref) => false); + +// Set to an eventId to make the open chat scroll to + briefly highlight that +// message (e.g. when tapping a search result). ChatView consumes and clears it. +final pendingJumpEventProvider = StateProvider((ref) => null); + +// Currently selected room ID (null = no room selected) +final activeRoomIdProvider = StateProvider((ref) => null); + +// ─── Share-Target ("Teilen nach Pyramid", Android) ─────────────────────────── +// Von MainActivity.kt via MethodChannel geliefert; AppShell zeigt dann den +// Raum-Picker. paths = bereits in den App-Cache kopierte Dateien. +class PendingShare { + final String? text; + final List paths; + const PendingShare({this.text, this.paths = const []}); + + bool get isEmpty => (text == null || text!.isEmpty) && paths.isEmpty; +} + +final pendingShareProvider = StateProvider((ref) => null); + +// Space filter: null = all rooms, otherwise space ID +final activeSpaceIdProvider = StateProvider((ref) => 'dms'); + +// ─── Modal state ───────────────────────────────────────────────────────────── + +enum ModalKind { none, settings, search } + +final activeModalProvider = StateProvider((ref) => ModalKind.none); + +// ─── Call state ─────────────────────────────────────────────────────────────── + +final callStateProvider = ChangeNotifierProvider((ref) { + return LiveKitCallManager.instance; +}); + +final voipStateProvider = ChangeNotifierProvider((ref) { + return PyramidVoipManager.instance; +}); + +final incomingCallProvider = StateProvider((ref) => null); + +// ─── View mode (chat / voice) ───────────────────────────────────────────────── + +enum ViewMode { chat, voice } + +final viewModeProvider = StateProvider((ref) => ViewMode.chat); + +// ─── Spaces from Matrix ─────────────────────────────────────────────────────── + +final spacesProvider = StreamProvider>((ref) async* { + final client = await ref.watch(matrixClientProvider.future); + yield _getSpaces(client); + await for (final _ in client.onSync.stream) { + yield _getSpaces(client); + } +}); + +List _getSpaces(Client client) { + return client.rooms.where((r) => r.isSpace).toList(); +} + +// ─── Toast notifications ───────────────────────────────────────────────────── + +class ToastMessage { + final String text; + final String icon; + ToastMessage(this.text, this.icon); +} + +final toastProvider = StateProvider((ref) => null); + +// Remembers the last active room per space section (key = spaceId or null) +final lastRoomPerSpaceProvider = StateProvider>((ref) => {}); + +// Space IDs the user just accepted an invite for. The server confirms the join +// immediately, but Continuwuity's incremental /sync sometimes does not flip the +// local membership from invite → join. We treat these as joined optimistically +// so the UI switches to the channel list (channels load via the space hierarchy). +// Persisted so the user does not have to re-accept on every app start. +final forceJoinedSpacesProvider = + StateNotifierProvider>( + (ref) => StringSetPref('force_joined_spaces')); + +// ─── Voice channel state ────────────────────────────────────────────────────── + +// ID of the Matrix room the user is currently in as a voice channel (null = none) +final activeVoiceRoomIdProvider = StateProvider((ref) => null); + +const kVoicePresenceType = 'io.pyramid.voice.presence'; + +// Local override cache: spaceId → bannerMxcUri (set immediately on upload, +// before the server sync delivers the state event back) +final spaceBannerOverrideProvider = + StateProvider>((ref) => {}); + +// ─── Server-level banner ────────────────────────────────────────────────────── +// Persisted to SharedPreferences keyed by homeserver URL. +// Key in store: homeserverUrl → mxcUri + +class ServerBannerNotifier extends AsyncNotifier> { + @override + Future> build() => loadAllServerBanners(); + + Future set(String homeserverUrl, String? mxcUri) async { + final current = Map.from(state.valueOrNull ?? {}); + if (mxcUri == null || mxcUri.isEmpty) { + current.remove(homeserverUrl); + } else { + current[homeserverUrl] = mxcUri; + } + await saveServerBanner(homeserverUrl, mxcUri); + state = AsyncData(current); + } +} + +final serverBannerProvider = + AsyncNotifierProvider>( + ServerBannerNotifier.new, +); + +// Active voice participants for a room, read from Matrix state events +final voiceParticipantsProvider = + Provider.family>, String>((ref, roomId) { + ref.watch(roomListProvider); // Rebuild on every sync so presence updates show + final client = ref.watch(matrixClientProvider).valueOrNull; + if (client == null) return []; + final room = client.getRoomById(roomId); + if (room == null) return []; + final states = room.states[kVoicePresenceType] ?? {}; + final now = DateTime.now().millisecondsSinceEpoch; + final result = >[]; + for (final entry in states.entries) { + final content = entry.value.content; + if (content['active'] != true) continue; + final joinedAt = content['joined_at'] as int? ?? 0; + if (now - joinedAt > 10 * 60 * 1000) continue; + result.add({ + 'userId': entry.key, + 'displayName': content['display_name'] as String? ?? entry.key, + 'avatarUrl': content['avatar_url'] as String?, + }); + } + + // Optimistically insert the local user immediately (state event may not + // have synced back from the server yet after joining) + final activeVoiceRoomId = ref.watch(activeVoiceRoomIdProvider); + if (activeVoiceRoomId == roomId && client.userID != null) { + final userId = client.userID!; + if (!result.any((p) => p['userId'] == userId)) { + String displayName = userId.split(':').first.replaceAll('@', ''); + String? avatarUrl; + for (final r in client.rooms) { + try { + final m = r.getParticipants().firstWhere((u) => u.id == userId); + if (m.displayName != null) { + displayName = m.displayName!; + avatarUrl = m.avatarUrl?.toString(); + break; + } + } catch (_) {} + } + result.insert(0, { + 'userId': userId, + 'displayName': displayName, + 'avatarUrl': avatarUrl, + }); + } + } + + return result; +}); diff --git a/lib/core/background_push.dart b/lib/core/background_push.dart new file mode 100644 index 0000000..1965283 --- /dev/null +++ b/lib/core/background_push.dart @@ -0,0 +1,402 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; +import 'package:matrix/matrix.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite/sqflite.dart' as sqflite_native; + +const _kChannel = 'pyramid_messages'; + +/// Port name used to locate the main isolate from background isolates. +/// Registered by notification_service.dart when the app starts. +const kMainIsolateName = 'pyramid_main'; + +int _stableId(String roomId) { + var h = 0; + for (final c in roomId.codeUnits) { + h = ((h * 31) + c) & 0x7FFFFFFF; + } + return h == 0 ? 1 : h; +} + +// ── FCM background handler ──────────────────────────────────────────────────── +// Runs in its own Dart isolate (or fresh process) when the app is killed or +// backgrounded. Firebase calls this after Firebase.initializeApp() is done. + +@pragma('vm:entry-point') +Future handleBackgroundMessage(RemoteMessage message) async { + // PushService.kt handles all FCM-triggered notifications natively. + // This callback is registered for API completeness but never fires because + // FlutterFirebaseMessagingService is removed from the manifest. +} + +// ── Background engine entrypoint (killed-app E2EE work) ─────────────────────── +// PushService.kt / ReplyReceiver bootstrap a headless FlutterEngine running this +// entrypoint when the app is KILLED (no main engine). It exposes a MethodChannel +// so native can ask us to (a) decrypt a push event and update its notification, +// or (b) send an encrypted reply. Building a Matrix client here is safe ONLY +// because native gates this on the main app being dead — never two clients on +// the same olm DB at once. + +const _kBgEngineChannel = 'chat.pyramid.pyramid/bg_engine'; + +@pragma('vm:entry-point') +void notificationEngineMain() { + WidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel(_kBgEngineChannel); + + channel.setMethodCallHandler((call) async { + try { + final args = (call.arguments as Map?)?.cast() ?? {}; + switch (call.method) { + case 'decryptAndShow': + return await _bgDecryptAndShow( + args['room_id'] as String?, + args['event_id'] as String?, + (args['notif_id'] as num?)?.toInt(), + ); + case 'sendReply': + return await _bgSendReply( + args['room_id'] as String?, + args['text'] as String?, + ); + } + } catch (e) { + print('[NOTIF-BGENGINE] handler error: $e'); + } + return false; + }); + + // Signal native that the channel handler is installed and tasks can be sent. + channel.invokeMethod('bgEngineReady'); +} + +Future _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) async { + if (roomId == null || eventId == null) return false; + final client = await _buildClient(); + if (client == null) return false; + try { + final event = await client + .getEventByPushNotification( + PushNotification(roomId: roomId, eventId: eventId, devices: const []), + storeInDatabase: false, + returnNullIfSeen: false, + ) + .timeout(const Duration(seconds: 20)); + if (event == null) return false; + // Bad-encrypted body still starts with "**" — keep the native placeholder. + if (event.body.startsWith('**')) return false; + + final room = client.getRoomById(roomId); + final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ?? + event.senderId.split(':').first.replaceFirst('@', ''); + final title = (room?.isDirectChat ?? true) + ? sender + : '$sender · ${room?.getLocalizedDisplayname() ?? ''}'; + + // Hand the decrypted content back to native so it re-shows the SAME + // notification via NotificationHelper — that keeps the reliable + // MainActivity-based reply action (the flutter_local_notifications reply + // path doesn't fire dependably when the app is killed). + await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', { + 'title': title, + 'body': _buildBody(event), + 'room_id': roomId, + 'notif_id': notifId ?? _stableId(roomId), + }); + return true; + } catch (e) { + print('[NOTIF-BGENGINE] decryptAndShow error: $e'); + return false; + } finally { + // Let the async key-backup upload settle before closing the DB — otherwise + // it logs a harmless "database_closed" error mid-flight. + await Future.delayed(const Duration(seconds: 2)); + await client.dispose().catchError((_) {}); + } +} + +Future _bgSendReply(String? roomId, String? text) async { + if (roomId == null || text == null || text.trim().isEmpty) return false; + final client = await _buildClient(); + if (client == null) return false; + try { + // A one-shot sync ensures the room and recipient device keys are present so + // the message can be encrypted. Safe here: main app is dead (native-gated). + await client.oneShotSync().timeout(const Duration(seconds: 20)).catchError((_) => null); + final room = client.getRoomById(roomId); + if (room == null) { + // Fall back to the prefs queue so the main app sends it on next launch. + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('notif_pending_reply_room', roomId); + await prefs.setString('notif_pending_reply_text', text); + return false; + } + await room.sendTextEvent(text.trim()); + print('[NOTIF-BGENGINE] encrypted reply sent to $roomId'); + return true; + } catch (e) { + print('[NOTIF-BGENGINE] sendReply error: $e'); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('notif_pending_reply_room', roomId); + await prefs.setString('notif_pending_reply_text', text); + return false; + } finally { + // Allow the encrypted send + any key upload to flush before closing the DB. + await Future.delayed(const Duration(seconds: 2)); + await client.dispose().catchError((_) {}); + } +} + +/// Stores the raw callback handle of [notificationEngineMain] so native code can +/// bootstrap the background engine. Call once at app startup. +Future registerBgEngineHandle() async { + if (!Platform.isAndroid) return; + try { + final handle = PluginUtilities.getCallbackHandle(notificationEngineMain); + if (handle == null) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('bg_engine_handle', handle.toRawHandle()); + } catch (e) { + print('[NOTIF-BGENGINE] registerBgEngineHandle failed: $e'); + } +} + +// ── Build a short-lived Matrix client for background work ──────────────────── +// Uses NativeImplementationsDummy so Vodozemac runs synchronously in this +// isolate — NativeImplementationsIsolate(compute) would try to spawn a +// sub-isolate, which fails inside a Firebase background isolate. + +Future _buildClient() async { + try { + // vodozemac may already be initialised in this isolate (e.g. a decrypt task + // ran before this reply task reused the same engine). Calling init() twice + // throws — swallow it so it doesn't fail the whole client build. + try { + await vod.init(); + } catch (_) {} + + final appDir = await getApplicationSupportDirectory(); + final dbPath = p.join(appDir.path, 'pyramid.sqlite'); + + final db = await sqflite_native.databaseFactory + .openDatabase(dbPath) + .timeout(const Duration(seconds: 5)); + + // Prevent "database is locked" if the main process still has the DB open. + try { + await db.execute('PRAGMA busy_timeout = 5000'); + await db.execute('PRAGMA journal_mode = WAL'); + } catch (_) {} + + final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db); + + final client = Client( + 'Pyramid', + database: sdkDb, + nativeImplementations: NativeImplementationsDummy(), + logLevel: Level.error, + ); + + await client + .init( + waitForFirstSync: false, + waitUntilLoadCompletedLoaded: false, + ) + .timeout(const Duration(seconds: 15)); + + return client; + } catch (e) { + print('[NOTIF-BGENGINE] _buildClient failed: $e'); + return null; + } +} + +String _buildBody(Event event) { + final msgType = event.messageType; + final rawBody = event.body; + return switch (msgType) { + 'm.text' => rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody, + 'm.image' => '📷 Bild', + 'm.video' => '🎥 Video', + 'm.audio' => '🎵 Audio', + 'm.file' => '📄 Datei', + 'm.sticker' => '🏷️ Sticker', + _ => rawBody.isEmpty ? 'Neue Nachricht' : (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody), + }; +} + +// ── Notification action handler ─────────────────────────────────────────────── +// Called in a fresh Dart isolate when the user presses Reply or Gelesen. +// Must return quickly — Android keeps the notification's "sending" spinner +// visible until this function returns. + +@pragma('vm:entry-point') +Future handleBackgroundNotificationResponse( + NotificationResponse details) async { + WidgetsFlutterBinding.ensureInitialized(); + + print('[NOTIF-BG] handleBackgroundNotificationResponse fired'); + print('[NOTIF-BG] actionId=${details.actionId} payload=${details.payload} input=${details.input} id=${details.id}'); + + // ── Fast path: main isolate is running ─────────────────────────────────── + // If the Pyramid UI process is alive (app is backgrounded but not killed), + // forward the action directly via IsolateNameServer. This avoids starting + // any new engine and works silently without bringing the app to foreground. + final sendPort = IsolateNameServer.lookupPortByName(kMainIsolateName); + if (sendPort != null) { + print('[NOTIF-BG] main isolate alive → forwarding via IsolateNameServer'); + sendPort.send({ + 'actionId': details.actionId, + 'payload': details.payload, + 'input': details.input, + 'id': details.id, + }); + return; + } + + // ── Slow path: app was killed ───────────────────────────────────────────── + // Main isolate is gone. Dismiss the notification and, for reply actions, + // persist the text in SharedPreferences so _sendPendingReply picks it up + // the next time the app opens (via getInitialReply cold-start flow). + print('[NOTIF-BG] main isolate not running → SharedPreferences fallback'); + + final plugin = FlutterLocalNotificationsPlugin(); + await plugin.initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@drawable/ic_notification')), + onDidReceiveBackgroundNotificationResponse: handleBackgroundNotificationResponse, + ); + + final roomId = details.payload; + if (roomId != null) { + await plugin.cancel(_stableId(roomId)); + print('[NOTIF-BG] cancelled notification for room $roomId'); + } else if (details.id != null) { + await plugin.cancel(details.id!); + print('[NOTIF-BG] cancelled notification id ${details.id}'); + } + + if (details.actionId != 'reply') { + print('[NOTIF-BG] not a reply action, done'); + return; + } + + final text = details.input?.trim() ?? ''; + if (text.isEmpty || roomId == null) { + print('[NOTIF-BG] text empty or roomId null, aborting'); + return; + } + + // App killed — send the encrypted reply directly from this background isolate. + // _bgSendReply falls back to the prefs queue if it can't send (so the main app + // delivers it on next launch). + final sent = await _bgSendReply(roomId, text); + print('[NOTIF-BG] background reply sent=$sent room=$roomId'); +} + +// ── Notification builder ────────────────────────────────────────────────────── + +Future _showNotif({ + required String title, + required String body, + required String? roomId, + required int notifId, +}) async { + final plugin = FlutterLocalNotificationsPlugin(); + await plugin.initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@drawable/ic_notification')), + onDidReceiveBackgroundNotificationResponse: handleBackgroundNotificationResponse, + ); + + await plugin.show( + notifId, title, body, + NotificationDetails( + android: AndroidNotificationDetails( + _kChannel, 'Nachrichten', + channelDescription: 'Neue Nachrichten', + importance: Importance.high, + priority: Priority.high, + icon: '@drawable/ic_notification', + color: const Color(0xFF7B61FF), + groupKey: 'pyramid_messages', + actions: roomId != null + ? const [ + AndroidNotificationAction( + 'reply', 'Antworten', + inputs: [AndroidNotificationActionInput(label: 'Antworten…')], + allowGeneratedReplies: true, + showsUserInterface: false, // BroadcastReceiver path — app never opens + ), + AndroidNotificationAction( + 'read', 'Gelesen', + showsUserInterface: false, + ), + ] + : const [], + ), + ), + payload: roomId, + ); +} + +// ── HTTP fallback — fetch sender / room name without the SDK ────────────────── + +Future _fetchTitle( + String hs, String token, String roomId, String eventId) async { + try { + final encRoom = Uri.encodeComponent(roomId); + final encEvent = Uri.encodeComponent(eventId); + + final rawEvent = await _get( + '$hs/_matrix/client/v3/rooms/$encRoom/event/$encEvent', token); + if (rawEvent == null) return null; + + final senderId = rawEvent['sender'] as String? ?? ''; + String senderName = senderId.split(':').first.replaceFirst('@', ''); + + if (senderId.isNotEmpty) { + final encSender = Uri.encodeComponent(senderId); + final memberState = await _get( + '$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.member/$encSender', + token); + final name = memberState?['displayname'] as String?; + if (name != null && name.isNotEmpty) senderName = name; + } + + final nameState = + await _get('$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name', token); + final roomName = nameState?['name'] as String?; + + return roomName?.isNotEmpty == true + ? '$senderName · $roomName' + : senderName; + } catch (_) { + return null; + } +} + +Future?> _get(String url, String token) async { + try { + final req = await HttpClient() + .getUrl(Uri.parse(url)) + .timeout(const Duration(seconds: 8)); + req.headers.add('Authorization', 'Bearer $token'); + final res = await req.close(); + if (res.statusCode != 200) return null; + final raw = await res.transform(utf8.decoder).join(); + return jsonDecode(raw) as Map; + } catch (_) { + return null; + } +} diff --git a/lib/core/e2ee_diagnostics.dart b/lib/core/e2ee_diagnostics.dart new file mode 100644 index 0000000..5615c6e --- /dev/null +++ b/lib/core/e2ee_diagnostics.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class E2eeDiagEntry { + final DateTime timestamp; + final String roomId; + final String eventId; + final String sessionId; + final String error; + + const E2eeDiagEntry({ + required this.timestamp, + required this.roomId, + required this.eventId, + required this.sessionId, + required this.error, + }); + + Map toJson() => { + 'ts': timestamp.toIso8601String(), + 'room': roomId, + 'event': eventId, + 'session': sessionId, + 'error': error, + }; +} + +class E2eeDiagnosticsNotifier extends Notifier> { + static const int _maxEntries = 500; + + @override + List build() => []; + + void add(E2eeDiagEntry entry) { + final next = [...state, entry]; + state = next.length > _maxEntries ? next.sublist(next.length - _maxEntries) : next; + } + + void clear() => state = []; + + Future upload({required String serverUrl, required String token}) async { + if (state.isEmpty) return 'Keine Diagnosedaten vorhanden.'; + + final payload = utf8.encode(jsonEncode({ + 'client': 'Pyramid', + 'uploaded_at': DateTime.now().toIso8601String(), + 'entry_count': state.length, + 'entries': state.map((e) => e.toJson()).toList(), + })); + + final client = HttpClient(); + try { + final uri = Uri.parse('$serverUrl/api/e2ee-diagnostics'); + final request = await client.postUrl(uri); + request.headers.set('Content-Type', 'application/json; charset=utf-8'); + request.headers.set('Authorization', 'Bearer $token'); + request.headers.contentLength = payload.length; + request.add(payload); + final response = await request.close(); + await response.drain(); + if (response.statusCode == 200) { + final count = state.length; + clear(); + return '$count Einträge hochgeladen und lokal gelöscht.'; + } + return 'Server-Fehler: HTTP ${response.statusCode}'; + } on SocketException catch (e) { + return 'Netzwerkfehler: ${e.message}'; + } catch (e) { + return 'Fehler: $e'; + } finally { + client.close(); + } + } +} + +final e2eeDiagnosticsProvider = + NotifierProvider>( + E2eeDiagnosticsNotifier.new, +); diff --git a/lib/core/fcm_push_service.dart b/lib/core/fcm_push_service.dart new file mode 100644 index 0000000..89a4d2c --- /dev/null +++ b/lib/core/fcm_push_service.dart @@ -0,0 +1,183 @@ +import 'dart:io'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:matrix/matrix.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// Self-hosted Sygnal instance on the Pi — must be configured with the +// Firebase service account for the 'chat-pyramid' Firebase project. +// push.element.io only supports Element's own apps and will actively reject +// chat.pyramid.pyramid pushkeys, causing the homeserver to auto-remove the pusher. +const _kGatewayUrl = 'https://push.steggi-matrix.work/_matrix/push/v1/notify'; +const _kAppId = 'chat.pyramid.pyramid'; + +// ── Foreground-Setup & Pusher-Registrierung ─────────────────────────────────── + +Future fcmDiagnostics(Client matrixClient) async { + if (!Platform.isAndroid) return 'Kein Android'; + final buf = StringBuffer(); + try { + await Firebase.initializeApp(); + buf.writeln('✓ Firebase initialisiert'); + } catch (e) { + buf.writeln('✗ Firebase-Init: $e'); + return buf.toString(); + } + try { + final settings = await FirebaseMessaging.instance.requestPermission(); + buf.writeln('✓ Berechtigung: ${settings.authorizationStatus.name}'); + } catch (e) { + buf.writeln('✗ Berechtigung: $e'); + } + try { + final token = await FirebaseMessaging.instance.getToken(); + if (token != null) { + buf.writeln('✓ FCM-Token: ${token.substring(0, 20)}…'); + } else { + buf.writeln('✗ FCM-Token: null'); + } + } catch (e) { + buf.writeln('✗ FCM-Token: $e'); + } + try { + final pushers = await matrixClient.getPushers() ?? []; + final fcmPushers = pushers.where((p) => p.appId == _kAppId).toList(); + if (fcmPushers.isEmpty) { + buf.writeln('✗ Pusher: keiner bei Synapse registriert!'); + } else { + buf.writeln('✓ Pusher: ${fcmPushers.length} registriert'); + } + } catch (e) { + buf.writeln('✗ Pusher-Abfrage: $e'); + } + return buf.toString(); +} + +Future initFcm(Client matrixClient) async { + if (!Platform.isAndroid) return; + + try { + await Firebase.initializeApp(); + } catch (_) { + // Bereits initialisiert (main.dart) oder google-services.json fehlt – weiter + } + + // Background messages are handled by handleBackgroundMessage (background_push.dart) + // via FlutterFirebaseMessagingService. It decrypts E2EE events using the Matrix + // SDK and shows notifications via flutter_local_notifications with + // showsUserInterface:false so no Activity opens when the user replies. + + // Notification-Berechtigung anfordern (Android 13+) + await FirebaseMessaging.instance.requestPermission(); + + // Foreground-Notifications unterdrücken — App zeigt selbst Nachrichten + await FirebaseMessaging.instance + .setForegroundNotificationPresentationOptions( + alert: false, + badge: false, + sound: false, + ); + + // Re-register pusher if token was refreshed while the app was killed. + final prefs = await SharedPreferences.getInstance(); + final pendingToken = prefs.getString('flutter.fcm_pending_token'); + if (pendingToken != null && matrixClient.isLogged()) { + await _registerPusher(matrixClient, pendingToken); + await prefs.remove('flutter.fcm_pending_token'); + } + + final token = pendingToken ?? await _getToken(); + if (token != null && matrixClient.isLogged()) { + await _registerPusher(matrixClient, token); + // Save homeserver + token for PushService.kt (Kotlin native notification handler) + await _saveNotifCredentials(matrixClient); + } + + // Bei Token-Refresh: Pusher + gespeicherte Credentials aktualisieren + FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async { + if (matrixClient.isLogged()) { + await _registerPusher(matrixClient, newToken); + await _saveNotifCredentials(matrixClient); + } + }); +} + +Future _saveNotifCredentials(Client client) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('notif_homeserver', client.homeserver.toString()); + await prefs.setString('notif_access_token', client.accessToken ?? ''); + } catch (_) {} +} + +Future _getToken() async { + try { + return await FirebaseMessaging.instance.getToken(); + } catch (_) { + return null; + } +} + +Future _registerPusher(Client client, String fcmToken) async { + try { + await client.postPusher( + Pusher( + pushkey: fcmToken, + kind: 'http', + appId: _kAppId, + appDisplayName: 'Pyramid', + deviceDisplayName: client.deviceName ?? 'Android', + lang: 'de', + data: PusherData( + url: Uri.parse(_kGatewayUrl), + format: 'event_id_only', + ), + ), + append: true, + ); + } catch (e) { + debugPrint('[FCM] Pusher-Registrierung fehlgeschlagen: $e'); + } +} + +/// Tries to register the FCM pusher and returns a human-readable result string. +Future retryPusherRegistration(Client client) async { + if (!Platform.isAndroid) return 'Kein Android'; + final buf = StringBuffer(); + try { + String? token; + try { + token = await FirebaseMessaging.instance.getToken(); + } catch (e) { + return '✗ FCM-Token: $e'; + } + if (token == null) { + return '✗ FCM-Token: null (Google Play Services Problem?)'; + } + buf.writeln('✓ FCM-Token: ${token.substring(0, 20)}…'); + buf.writeln(' Gateway: $_kGatewayUrl'); + await client.postPusher( + Pusher( + pushkey: token, + kind: 'http', + appId: _kAppId, + appDisplayName: 'Pyramid', + deviceDisplayName: client.deviceName ?? 'Android', + lang: 'de', + data: PusherData( + url: Uri.parse(_kGatewayUrl), + format: 'event_id_only', + ), + ), + append: true, + ); + buf.writeln('✓ Pusher registriert'); + await _saveNotifCredentials(client); + buf.writeln('✓ Credentials gespeichert'); + } catch (e) { + buf.writeln('✗ Fehler: $e'); + } + return buf.toString().trim(); +} diff --git a/lib/core/ice_servers.dart b/lib/core/ice_servers.dart new file mode 100644 index 0000000..80020cb --- /dev/null +++ b/lib/core/ice_servers.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// Zentrale Quelle für ICE-Server (STUN/TURN), die in ALLE WebRTC-Verbindungen +/// injiziert werden (Matrix-1:1-Calls und LiveKit). +/// +/// Hintergrund: Der Heimserver hängt hinter CGNAT (keine Portfreigabe möglich). +/// Direkte Verbindungen brauchen deshalb ICE-Hole-Punching mit STUN auf beiden +/// Seiten; für harte Fälle (symmetrische Mobilfunk-NAT) können später +/// TURN-Server (z. B. Cloudflare TURN, 1 TB/Monat gratis) ergänzt werden — +/// dafür muss nur die ice.json auf dem Pi aktualisiert werden, ohne App-Update. +class IceServers { + /// Wird vom Pi ausgeliefert und kann dort per Cron mit frischen + /// TURN-Credentials befüllt werden (siehe /home/steggi/matrix/ice.json). + static const _remoteUrl = + 'https://steggi-matrix.work/.well-known/pyramid/ice.json'; + + /// Fallback, falls die ice.json nicht erreichbar ist. + static const List> _fallback = [ + { + 'urls': [ + 'stun:stun.l.google.com:19302', + 'stun:stun.cloudflare.com:3478', + ], + }, + ]; + + static List>? _cached; + static DateTime? _fetchedAt; + + /// Liefert die ICE-Server als WebRTC-`iceServers`-Maps + /// (`{'urls': [...], 'username': ..., 'credential': ...}`). + /// Ergebnis wird 30 Minuten gecached; TURN-Credentials in der ice.json + /// sollten deutlich länger gültig sein. + static Future>> get() async { + final cached = _cached; + if (cached != null && + _fetchedAt != null && + DateTime.now().difference(_fetchedAt!) < + const Duration(minutes: 30)) { + return cached; + } + try { + final resp = await http + .get(Uri.parse(_remoteUrl)) + .timeout(const Duration(seconds: 4)); + if (resp.statusCode == 200) { + final body = jsonDecode(resp.body); + final list = (body['ice_servers'] as List) + .whereType() + .map((e) => e.cast()) + .toList(); + if (list.isNotEmpty) { + _cached = list; + _fetchedAt = DateTime.now(); + return list; + } + } + } catch (_) { + // Offline/Endpoint fehlt — Fallback unten. + } + return _cached ?? _fallback; + } +} diff --git a/lib/core/livekit_call_manager.dart b/lib/core/livekit_call_manager.dart new file mode 100644 index 0000000..29e1119 --- /dev/null +++ b/lib/core/livekit_call_manager.dart @@ -0,0 +1,606 @@ +// ignore_for_file: avoid_print +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; +import 'package:livekit_client/livekit_client.dart'; +import 'package:matrix/matrix.dart' hide Room; +import 'package:pyramid/core/ice_servers.dart'; +import 'package:pyramid/core/livekit_token.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _kVoicePresenceType = 'io.pyramid.voice.presence'; + +class LiveKitCallManager with ChangeNotifier { + static final LiveKitCallManager instance = LiveKitCallManager._(); + LiveKitCallManager._(); + + // ── Publish quality presets (Streamer → Server) ────────────────────────── + static final Map qualityPresets = { + 'sd': VideoParametersPresets.h360_169, + 'hd': VideoParametersPresets.h720_169, + 'fhd': VideoParametersPresets.h1080_169, + '4k': VideoParametersPresets.h2160_169, + }; + + static const Map _screenShareEncodings = { + 'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10), + 'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15), + 'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20), + '4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30), + }; + + Room? _room; + EventsListener? _roomEventListener; + String _displayName = ''; + bool isConnecting = false; + String? error; + bool isMuted = false; + bool isCameraOff = false; + bool isScreenSharing = false; + String _localQualityKey = 'hd'; + String _subscribeQualityKey = 'auto'; // 'auto' | 'low' | 'medium' | 'high' + String? _currentScreenShareSourceId; + LocalVideoTrack? _screenShareTrack; + bool isDeafened = false; + bool _mutedBeforeDeafen = false; + bool isVoiceChannel = false; + String? _matrixRoomId; + Client? _matrixClient; + Timer? _presenceHeartbeat; + + Timer? _statsTimer; + + bool get isActive => _room != null || isConnecting; + Room? get room => _room; + String? get currentRoomId => _matrixRoomId; + String get displayName => _displayName; + bool get noiseSuppression => false; + String get currentQualityKey => _localQualityKey; + String get subscribeQualityKey => _subscribeQualityKey; + + VideoParameters get _currentQuality => + qualityPresets[_localQualityKey] ?? VideoParametersPresets.h720_169; + + void _onRoomChanged() => notifyListeners(); + + void _cleanUpAfterDisconnect() { + _stopStatsLogging(); + _presenceHeartbeat?.cancel(); + _presenceHeartbeat = null; + if (_matrixClient != null && _matrixRoomId != null) { + _writePresence(false).catchError((_) {}); + } + _roomEventListener?.dispose(); + _roomEventListener = null; + _room?.removeListener(_onRoomChanged); + _room = null; + _displayName = ''; + isConnecting = false; + error = null; + isMuted = false; + isCameraOff = false; + isScreenSharing = false; + isDeafened = false; + isVoiceChannel = false; + _mutedBeforeDeafen = false; + _screenShareTrack = null; + _currentScreenShareSourceId = null; + _matrixRoomId = null; + _matrixClient = null; + notifyListeners(); + } + + Future _writePresence(bool active) async { + final client = _matrixClient; + final roomId = _matrixRoomId; + final userId = client?.userID; + if (client == null || roomId == null || userId == null) return; + try { + Map content; + if (active) { + // Resolve display name + avatar from any joined room's member state + User? member; + for (final r in client.rooms) { + final participants = r.getParticipants(); + try { + member = participants.firstWhere((u) => u.id == userId); + if (member.displayName != null || member.avatarUrl != null) break; + } catch (_) {} + } + content = { + 'active': true, + 'joined_at': DateTime.now().millisecondsSinceEpoch, + 'display_name': member?.displayName ?? userId, + if (member?.avatarUrl != null) 'avatar_url': member!.avatarUrl.toString(), + }; + } else { + content = {'active': false}; + } + await client.setRoomStateWithKey(roomId, _kVoicePresenceType, userId, content); + } catch (e) { + print('[VoicePresence] $e'); + } + } + + Future startCall({ + required String roomName, + required String roomDisplayName, + required String identity, + bool audioOnly = false, + bool voiceChannel = false, + Client? matrixClient, + String? matrixRoomId, + }) async { + if (isActive) return; + + final url = 'wss://livekit.steggi-matrix.work'; + + isConnecting = true; + isVoiceChannel = voiceChannel; + error = null; + _displayName = roomDisplayName; + isCameraOff = audioOnly; + isMuted = false; + isScreenSharing = false; + isDeafened = false; + _mutedBeforeDeafen = false; + _localQualityKey = 'hd'; + _subscribeQualityKey = 'auto'; + _matrixClient = matrixClient; + _matrixRoomId = matrixRoomId; + notifyListeners(); + + try { + final token = LiveKitTokenGenerator.generate( + roomName: roomName, + identity: identity, + displayName: identity, + ttlSeconds: 21600, + ); + + final room = Room( + roomOptions: const RoomOptions( + adaptiveStream: false, // Matches FluffyChat fix + dynacast: true, + ), + ); + room.addListener(_onRoomChanged); + + // Eigene ICE-Server (STUN/TURN aus ice.json) mitgeben. Client-seitige + // iceServers ERSETZEN die vom LiveKit-Server gelieferten — die zeigten + // bisher auf einen hinter CGNAT unerreichbaren coturn. + RTCConfiguration rtcConfig = const RTCConfiguration(); + try { + final ice = await IceServers.get(); + rtcConfig = RTCConfiguration( + iceServers: ice + .map((m) => RTCIceServer( + urls: m['urls'] is List + ? List.from(m['urls'] as List) + : [m['urls'] as String], + username: m['username'] as String?, + credential: m['credential'] as String?, + )) + .toList(), + ); + } catch (e) { + print('[LiveKit] ICE server fetch error: $e'); + } + + await room.connect( + url, + token, + connectOptions: ConnectOptions(rtcConfiguration: rtcConfig), + ); + + _room = room; + isConnecting = false; + notifyListeners(); + + if (voiceChannel && matrixClient != null && matrixRoomId != null) { + await _writePresence(true); + _presenceHeartbeat = Timer.periodic(const Duration(seconds: 30), (_) { + _writePresence(true).catchError((_) {}); + }); + } + + _roomEventListener = room.createListener() + ..on((_) => notifyListeners()) + ..on((event) { + if (event.publication.source == TrackSource.screenShareVideo) { + isScreenSharing = false; + _currentScreenShareSourceId = null; + _screenShareTrack = null; + } + notifyListeners(); + }) + ..on((event) { + final pub = event.publication; + if (_subscribeQualityKey != 'auto' && pub.kind == TrackType.VIDEO) { + pub.setVideoQuality(_videoQualityFromKey(_subscribeQualityKey)); + } + // Ausgabelautstärke aus den Einstellungen auf neue Audio-Tracks. + if (pub.kind == TrackType.AUDIO) { + try { + rtc.Helper.setVolume( + _outputVolume.clamp(0.0, 2.0), event.track.mediaStreamTrack); + } catch (_) {} + } + notifyListeners(); + }) + ..on((_) => notifyListeners()) + ..on((_) => notifyListeners()) + ..on((_) => notifyListeners()) + ..on((_) => notifyListeners()) + ..on((_) => notifyListeners()) + ..on((_) { + _cleanUpAfterDisconnect(); + }); + + // In den Einstellungen gewählte Geräte anwenden (voice_*_device Prefs). + // Stale-IDs (abgestecktes Headset etc.) werden gegen die aktuell + // vorhandenen Geräte validiert und sonst ignoriert. + String? prefMic, prefSpeaker, prefCam; + var deviceIds = {}; + try { + final prefs = await SharedPreferences.getInstance(); + _outputVolume = prefs.getDouble('voice_output_volume') ?? 1.0; + prefMic = prefs.getString('voice_mic_device'); + prefSpeaker = prefs.getString('voice_speaker_device'); + prefCam = prefs.getString('voice_camera_device'); + final devices = await rtc.navigator.mediaDevices.enumerateDevices(); + deviceIds = devices.map((d) => d.deviceId).toSet(); + } catch (e) { + print('[LiveKit] Device prefs/enumerate error: $e'); + } + if (prefMic != null && !deviceIds.contains(prefMic)) prefMic = null; + if (prefSpeaker != null && !deviceIds.contains(prefSpeaker)) prefSpeaker = null; + if (prefCam != null && !deviceIds.contains(prefCam)) prefCam = null; + + await room.localParticipant?.setMicrophoneEnabled( + true, + audioCaptureOptions: + prefMic != null ? AudioCaptureOptions(deviceId: prefMic) : null, + ); + + if (prefSpeaker != null) { + try { + await rtc.Helper.selectAudioOutput(prefSpeaker); + } catch (e) { + print('[LiveKit] selectAudioOutput error: $e'); + } + } + + if (!audioOnly) { + try { + await room.localParticipant?.setCameraEnabled( + true, + cameraCaptureOptions: CameraCaptureOptions( + deviceId: prefCam, + params: _currentQuality, + ), + ); + } catch (e) { + print('[LiveKit] Failed to enable camera: $e'); + } + } + + _startStatsLogging(); + } catch (e) { + print('[VOIP] Start call error: $e'); + isConnecting = false; + error = e.toString(); + _cleanUpAfterDisconnect(); + } + } + + VideoQuality _videoQualityFromKey(String key) { + switch (key) { + case 'low': return VideoQuality.LOW; + case 'medium': return VideoQuality.MEDIUM; + case 'high': return VideoQuality.HIGH; + default: return VideoQuality.HIGH; // auto defaults to high + } + } + + // ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ───────────── + double _outputVolume = 1.0; + + /// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks. + Future setOutputVolume(double v) async { + _outputVolume = v; + final room = _room; + if (room == null) return; + for (final p in room.remoteParticipants.values) { + for (final pub in p.audioTrackPublications) { + final t = pub.track; + if (t != null) { + try { + await rtc.Helper.setVolume(v.clamp(0.0, 2.0), t.mediaStreamTrack); + } catch (_) {} + } + } + } + } + + Future setSubscribeQuality(String key) async { + print('[LiveKit] Viewer changing subscribe quality to: $key'); + _subscribeQualityKey = key; + if (_room == null) { notifyListeners(); return; } + + final quality = _videoQualityFromKey(key); + for (final p in _room!.remoteParticipants.values) { + for (final pub in p.videoTrackPublications) { + if (pub.kind == TrackType.VIDEO) { + print('[LiveKit] Setting subscription quality for ${p.identity} to $quality'); + pub.setVideoQuality(quality); + } + } + } + notifyListeners(); + } + + Future toggleMute() async { + isMuted = !isMuted; + notifyListeners(); + await _room?.localParticipant?.setMicrophoneEnabled(!isMuted); + } + + Future toggleDeafen() async { + if (!isDeafened) { + _mutedBeforeDeafen = isMuted; + isDeafened = true; + if (!isMuted) { + await _room?.localParticipant?.setMicrophoneEnabled(false); + isMuted = true; + } + } else { + isDeafened = false; + if (!_mutedBeforeDeafen) { + await _room?.localParticipant?.setMicrophoneEnabled(true); + isMuted = false; + } + } + _applyDeafen(); + notifyListeners(); + } + + void _applyDeafen() { + if (_room == null) return; + for (final p in _room!.remoteParticipants.values) { + for (final trackPub in p.audioTrackPublications) { + final track = trackPub.track; + if (track != null) { + track.mediaStreamTrack.enabled = !isDeafened; + } + } + } + } + + Future toggleCamera() async { + if (isCameraOff) { + try { + await _room?.localParticipant?.setCameraEnabled( + true, + cameraCaptureOptions: CameraCaptureOptions(params: _currentQuality), + ); + isCameraOff = false; + } catch (e) { + print('[LiveKit] toggleCamera ON error: $e'); + } + } else { + try { + await _room?.localParticipant?.setCameraEnabled(false); + } catch (e) { + print('[LiveKit] toggleCamera OFF error: $e'); + } + isCameraOff = true; + } + notifyListeners(); + } + + Future toggleScreenShare() async { + if (isScreenSharing) { + await stopScreenShare(); + } else { + try { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows) { + final sources = await rtc.desktopCapturer.getSources(types: [rtc.SourceType.Screen, rtc.SourceType.Window]); + if (sources.isNotEmpty) { + await startScreenShare(sources.first.id); + return; + } + } + + await _room?.localParticipant?.setScreenShareEnabled(true); + isScreenSharing = true; + notifyListeners(); + } catch (e) { + print('[ScreenShare] Toggle error: $e'); + } + } + } + + VideoEncoding _screenShareEncoding() { + return _screenShareEncodings[_localQualityKey] ?? + const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15); + } + + Future startScreenShare(String sourceId) async { + if (isScreenSharing) return; + try { + final encoding = _screenShareEncoding(); + final track = await LocalVideoTrack.createScreenShareTrack( + ScreenShareCaptureOptions( + sourceId: sourceId, + maxFrameRate: encoding.maxFramerate.toDouble(), + ), + ); + + if (_room == null || _room!.localParticipant == null) return; + + await _room!.localParticipant!.publishVideoTrack( + track, + publishOptions: VideoPublishOptions( + simulcast: true, + degradationPreference: DegradationPreference.balanced, + screenShareEncoding: encoding, + ), + ); + + isScreenSharing = true; + _currentScreenShareSourceId = sourceId; + _screenShareTrack = track; + notifyListeners(); + unawaited(_applyFpsToAllSimulcastLayers(track)); + } catch (e) { + print('[ScreenShare] ERROR: $e'); + } + } + + Future _applyFpsToAllSimulcastLayers(LocalVideoTrack track) async { + final targetFps = _screenShareEncoding().maxFramerate; + for (var attempt = 1; attempt <= 5; attempt++) { + await Future.delayed(const Duration(milliseconds: 600)); + if (!isScreenSharing || _screenShareTrack != track) return; + final sender = track.transceiver?.sender; + if (sender == null) continue; + final params = sender.parameters; + final encodings = params.encodings; + if (encodings == null || encodings.isEmpty) continue; + var anyUpdated = false; + for (final enc in encodings) { + if ((enc.maxFramerate ?? 0) < targetFps) { + enc.maxFramerate = targetFps; + anyUpdated = true; + } + } + if (anyUpdated) await sender.setParameters(params); + return; + } + } + + Future stopScreenShare() async { + final local = _room?.localParticipant; + if (local != null) { + final toStop = []; + for (final pub in local.videoTrackPublications) { + if (pub.source == TrackSource.screenShareVideo) { + toStop.add(pub.sid); + await pub.track?.stop(); + } + } + for (final sid in toStop) { + try { await local.removePublishedTrack(sid); } catch (_) {} + } + } + try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {} + isScreenSharing = false; + _currentScreenShareSourceId = null; + _screenShareTrack = null; + notifyListeners(); + } + + Future hangUp() async { + _stopStatsLogging(); + _presenceHeartbeat?.cancel(); + _presenceHeartbeat = null; + if (_matrixClient != null && _matrixRoomId != null) { + await _writePresence(false).catchError((_) {}); + } + _roomEventListener?.dispose(); + _roomEventListener = null; + _room?.removeListener(_onRoomChanged); + await _room?.disconnect(); + _room = null; + _displayName = ''; + isConnecting = false; + error = null; + isMuted = false; + isCameraOff = false; + isScreenSharing = false; + isDeafened = false; + isVoiceChannel = false; + _mutedBeforeDeafen = false; + _screenShareTrack = null; + _currentScreenShareSourceId = null; + _matrixRoomId = null; + _matrixClient = null; + notifyListeners(); + } + + Future changeQuality(String qualityKey) async { + print('[LiveKit] Streamer changing publish quality to: $qualityKey'); + _localQualityKey = qualityKey; + final params = qualityPresets[qualityKey]; + if (params == null) { notifyListeners(); return; } + if (_room == null) { notifyListeners(); return; } + + final local = _room!.localParticipant; + if (local == null) { notifyListeners(); return; } + + if (!isCameraOff) { + await local.setCameraEnabled(false); + await Future.delayed(const Duration(milliseconds: 400)); + try { + await local.setCameraEnabled( + true, + cameraCaptureOptions: CameraCaptureOptions(params: params), + ); + } catch (e) { + print('[LiveKit] Camera restart error: $e'); + isCameraOff = true; + } + } + + if (isScreenSharing && _currentScreenShareSourceId != null) { + final sid = _currentScreenShareSourceId!; + await stopScreenShare(); + await Future.delayed(const Duration(milliseconds: 400)); + await startScreenShare(sid); + } + notifyListeners(); + } + + Future changeAudioInput(String deviceId) async { + final participant = _room?.localParticipant; + if (participant == null) return; + try { + final track = await LocalAudioTrack.create(AudioCaptureOptions(deviceId: deviceId)); + for (final pub in List.of(participant.audioTrackPublications)) { + await participant.removePublishedTrack(pub.sid); + } + await participant.publishAudioTrack(track); + if (isMuted) await participant.setMicrophoneEnabled(false); + } catch (e) { print('[Audio] error: $e'); } + } + + void setRemoteVolume(double volume) { + if (_room == null) return; + final mute = volume < 0.05; + for (final p in _room!.remoteParticipants.values) { + for (final pub in p.audioTrackPublications) { + pub.track?.mediaStreamTrack.enabled = !mute; + } + } + } + + void _applyNoiseSuppression(bool enabled) {} + + void _startStatsLogging() { + _stopStatsLogging(); + _statsTimer = Timer.periodic(const Duration(seconds: 5), (_) => _logStats()); + } + + void _stopStatsLogging() { + _statsTimer?.cancel(); + _statsTimer = null; + } + + Future _logStats() async { + if (_room == null) return; + // Basic stats logging... + print('[Stats] SubQuality: $_subscribeQualityKey | PubQuality: $_localQualityKey'); + } +} diff --git a/lib/core/livekit_token.dart b/lib/core/livekit_token.dart new file mode 100644 index 0000000..aa5e257 --- /dev/null +++ b/lib/core/livekit_token.dart @@ -0,0 +1,37 @@ +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; + +class LiveKitTokenGenerator { + // Your LiveKit Credentials + static const String apiKey = 'LKMatrixPi'; + static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE'; + + static String generate({ + required String roomName, + required String identity, + String? displayName, + int ttlSeconds = 3600, // Valid for 1 hour by default + }) { + final jwt = JWT( + { + 'video': { + 'roomJoin': true, + 'room': roomName, + 'canPublish': true, + 'canSubscribe': true, + 'canPublishData': true, + }, + 'metadata': displayName ?? identity, + }, + issuer: apiKey, + subject: identity, + ); + + // Sign the token with your secret + final token = jwt.sign( + SecretKey(apiSecret), + expiresIn: Duration(seconds: ttlSeconds), + ); + + return token; + } +} diff --git a/lib/core/matrix_client.dart b/lib/core/matrix_client.dart index 0a289eb..24402dc 100644 --- a/lib/core/matrix_client.dart +++ b/lib/core/matrix_client.dart @@ -3,25 +3,78 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; +import 'package:matrix/encryption/utils/key_verification.dart'; import 'package:matrix/matrix.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart' as sqflite_native; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:pyramid/core/voip_manager.dart'; final matrixClientProvider = FutureProvider((ref) async { - if (!kIsWeb && (Platform.isWindows || Platform.isLinux)) { + final DatabaseFactory factory; + + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { + // Use the native sqflite plugin — Android/iOS have system SQLite built-in, + // no .so loading required. + factory = sqflite_native.databaseFactory; + } else if (!kIsWeb) { sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; + factory = databaseFactoryFfi; + } else { + throw UnsupportedError('Web not supported'); } final appDir = await getApplicationSupportDirectory(); final dbPath = p.join(appDir.path, 'pyramid.sqlite'); - final db = await databaseFactoryFfi.openDatabase(dbPath); + final db = await factory.openDatabase(dbPath); + + // Optimization for Windows/FFI to prevent "database is locked" errors + try { + await db.execute('PRAGMA journal_mode=WAL'); + await db.execute('PRAGMA busy_timeout = 5000'); + } catch (e) { + if (kDebugMode) print('Could not set PRAGMAs: $e'); + } + final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db); - final client = Client('Pyramid', database: sdkDb); - await client.init(); + final client = Client( + 'Pyramid', + database: sdkDb, + // Custom State-Events, die beim App-Start mit aus der DB geladen werden + // müssen — sonst sind Space-Banner & Voice-Presence nach Neustart leer, + // bis der jeweilige Raum vollständig nachgeladen wird. + importantStateEvents: { + 'io.pyramid.space.banner', + 'io.pyramid.voice.presence', + }, + verificationMethods: { + KeyVerificationMethod.numbers, + KeyVerificationMethod.emoji, + KeyVerificationMethod.qrShow, + }, + logLevel: kReleaseMode ? Level.warning : Level.verbose, + nativeImplementations: NativeImplementationsIsolate( + compute, + vodozemacInit: () => vod.init(), + ), + ); + + await client.init( + waitForFirstSync: false, + waitUntilLoadCompletedLoaded: false, + ); + + // Re-use or create singleton instance + try { + PyramidVoipManager.instance.client; + } catch (_) { + PyramidVoipManager(client); + } + return client; }); diff --git a/lib/core/media_cache.dart b/lib/core/media_cache.dart new file mode 100644 index 0000000..384fcd8 --- /dev/null +++ b/lib/core/media_cache.dart @@ -0,0 +1,139 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Shared, bounded LRU cache for media bytes (chat images, document previews, +/// avatars). Eviction is driven by a total byte budget plus a hard entry cap, +/// so the most-recently-viewed media (i.e. your recent messages) stays instantly +/// available while older media is dropped and re-fetched on demand. +/// +/// In addition to the in-memory layer there is a small disk layer for +/// persistent entries (avatars, banners), so they appear instantly after an +/// app restart instead of popping in once re-downloaded. +class MediaCache { + MediaCache._(); + static final MediaCache instance = MediaCache._(); + + // ~256 MB in memory, capped at 300 entries — comfortably covers the last + // ~100 messages of media per active chat without unbounded growth. + static const int _maxBytes = 256 * 1024 * 1024; + static const int _maxEntries = 300; + // Don't cache very large single files (videos etc.) — they'd evict everything. + static const int _maxEntryBytes = 32 * 1024 * 1024; + + // Disk layer: only small entries (avatars, banners), bounded total size. + static const int _maxDiskEntryBytes = 1024 * 1024; // 1 MB pro Eintrag + static const int _maxDiskTotalBytes = 64 * 1024 * 1024; // 64 MB gesamt + + final LinkedHashMap _entries = LinkedHashMap(); + int _totalBytes = 0; + + Directory? _diskDir; + bool _pruneScheduled = false; + + Uint8List? get(String key) { + final v = _entries.remove(key); + if (v != null) _entries[key] = v; // move to most-recently-used position + return v; + } + + void put(String key, Uint8List bytes) { + if (bytes.lengthInBytes > _maxEntryBytes) return; + + final existing = _entries.remove(key); + if (existing != null) _totalBytes -= existing.lengthInBytes; + + _entries[key] = bytes; + _totalBytes += bytes.lengthInBytes; + + _evict(); + } + + /// Memory lookup first, then disk. Disk hits are promoted back into memory. + Future getPersistent(String key) async { + final mem = get(key); + if (mem != null) return mem; + try { + final file = await _diskFile(key); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + if (bytes.isNotEmpty) { + put(key, bytes); + // Touch für LRU-Pruning nach mtime. + file.setLastModified(DateTime.now()).catchError((_) {}); + return bytes; + } + } + } catch (_) {} + return null; + } + + /// Stores in memory and (for small entries) on disk, surviving restarts. + Future putPersistent(String key, Uint8List bytes) async { + put(key, bytes); + if (bytes.lengthInBytes > _maxDiskEntryBytes) return; + try { + final file = await _diskFile(key); + await file.writeAsBytes(bytes); + } catch (_) {} + } + + Future _diskFile(String key) async { + final dir = _diskDir ??= await _initDiskDir(); + final name = sha1.convert(utf8.encode(key)).toString(); + return File('${dir.path}${Platform.pathSeparator}$name.bin'); + } + + Future _initDiskDir() async { + final support = await getApplicationSupportDirectory(); + final dir = Directory( + '${support.path}${Platform.pathSeparator}media_cache'); + await dir.create(recursive: true); + _schedulePrune(dir); + return dir; + } + + /// Once per session: drop the oldest disk entries when over budget. + void _schedulePrune(Directory dir) { + if (_pruneScheduled) return; + _pruneScheduled = true; + Future(() async { + try { + final files = {}; + var total = 0; + await for (final e in dir.list()) { + if (e is! File) continue; + final stat = await e.stat(); + files[e] = stat; + total += stat.size; + } + if (total <= _maxDiskTotalBytes) return; + final sorted = files.entries.toList() + ..sort((a, b) => a.value.modified.compareTo(b.value.modified)); + for (final entry in sorted) { + if (total <= _maxDiskTotalBytes) break; + total -= entry.value.size; + await entry.key.delete().catchError((_) => entry.key); + } + } catch (_) {} + }); + } + + void _evict() { + while ((_totalBytes > _maxBytes || _entries.length > _maxEntries) && + _entries.isNotEmpty) { + final oldestKey = _entries.keys.first; + final removed = _entries.remove(oldestKey); + if (removed != null) _totalBytes -= removed.lengthInBytes; + } + } + + void clear() { + _entries.clear(); + _totalBytes = 0; + } +} diff --git a/lib/core/notification_service.dart b/lib/core/notification_service.dart new file mode 100644 index 0000000..9173185 --- /dev/null +++ b/lib/core/notification_service.dart @@ -0,0 +1,660 @@ +import 'dart:io'; +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:local_notifier/local_notifier.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/background_push.dart'; +import 'package:pyramid/core/fcm_push_service.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/settings_prefs.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:windows_taskbar/windows_taskbar.dart'; + +const _kInstallChannel = MethodChannel('chat.pyramid.pyramid/install'); + +// ─── Stable notification ID per room (same algorithm as PushService.kt) ────── +// Dart's String.hashCode is randomised per-process; this gives a deterministic +// integer that matches the Kotlin side so FCM and sync notifications collapse. +int _stableRoomId(String roomId) { + var h = 0; + for (final c in roomId.codeUnits) { + h = ((h * 31) + c) & 0x7FFFFFFF; + } + return h == 0 ? 1 : h; +} + +// ─── Android plugin ─────────────────────────────────────────────────────────── + +final _androidPlugin = FlutterLocalNotificationsPlugin(); +bool _androidInitialized = false; + +// ─── IsolateNameServer port ─────────────────────────────────────────────────── +// Registered on startup so background isolates can forward notification actions +// to the running main isolate instead of starting a new Flutter engine. +ReceivePort? _mainIsolatePort; + +// Room → active Windows notification (so we can close it on room-open). +final _windowsNotifs = {}; + +// ─── Notification tap payload → navigate to room ────────────────────────────── + +final notificationTapProvider = StateProvider((ref) => null); + +// ─── Permission state (Android) ─────────────────────────────────────────────── + +/// True when the user has permanently denied notification permission on Android. +final notificationsBlockedProvider = StateProvider((ref) => false); + +// ─── Init ───────────────────────────────────────────────────────────────────── + +Future initNotifications(WidgetRef ref) async { + if (Platform.isAndroid) { + await _initAndroid(ref); + } else if (Platform.isWindows) { + await _initWindows(); + } +} + +Future _initAndroid(WidgetRef ref) async { + if (_androidInitialized) return; + _androidInitialized = true; + + // Register the IsolateNameServer port so background isolates can forward + // notification actions to us instead of starting a new Flutter engine. + _mainIsolatePort?.close(); + _mainIsolatePort = ReceivePort(); + IsolateNameServer.removePortNameMapping(kMainIsolateName); + IsolateNameServer.registerPortWithName( + _mainIsolatePort!.sendPort, kMainIsolateName); + _mainIsolatePort!.listen((msg) => _onIsolateMessage(msg, ref)); + print('[NOTIF] IsolateNameServer port registered: $kMainIsolateName'); + + await _androidPlugin.initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@drawable/ic_notification'), + ), + onDidReceiveNotificationResponse: (details) { + print('[NOTIF-FG] onDidReceiveNotificationResponse fired'); + print('[NOTIF-FG] actionId=${details.actionId} payload=${details.payload} input=${details.input}'); + if (details.actionId == 'reply') { + final text = details.input?.trim() ?? ''; + final roomId = details.payload; + print('[NOTIF-FG] reply: text="$text" roomId=$roomId'); + if (text.isNotEmpty && roomId != null && roomId.isNotEmpty) { + _androidPlugin.cancel(_stableRoomId(roomId)); + ref.read(matrixClientProvider.future).then((client) async { + final room = client.getRoomById(roomId); + print('[NOTIF-FG] getRoomById → ${room != null ? 'found' : 'null'}'); + if (room != null) { + try { + await room.sendTextEvent(text); + print('[NOTIF-FG] sendTextEvent OK'); + } catch (e) { + print('[NOTIF-FG] sendTextEvent failed: $e — storing in prefs'); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('notif_pending_reply_room', roomId); + await prefs.setString('notif_pending_reply_text', text); + } + } else { + print('[NOTIF-FG] room null — storing in prefs, waiting for sync'); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('notif_pending_reply_room', roomId); + await prefs.setString('notif_pending_reply_text', text); + try { + await client.onSync.stream.first; + print('[NOTIF-FG] sync arrived — retrying _sendPendingReply'); + await _sendPendingReply(ref); + } catch (e) { + print('[NOTIF-FG] sync/send error: $e'); + } + } + }); + } else { + print('[NOTIF-FG] reply skipped: text empty or roomId null'); + } + return; + } + if (details.actionId == 'read') { + final roomId = details.payload; + if (roomId != null) _androidPlugin.cancel(_stableRoomId(roomId)); + print('[NOTIF-FG] read action — dismissed'); + return; + } + final roomId = details.payload; + print('[NOTIF-FG] tap → opening room $roomId'); + if (roomId != null && roomId.isNotEmpty) { + ref.read(activeRoomIdProvider.notifier).state = roomId; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + } + }, + // Registers the background action handler so flutter_local_notifications + // can invoke it in a fresh isolate when the app is killed and the user + // presses Reply or Gelesen on a background notification. + onDidReceiveBackgroundNotificationResponse: + handleBackgroundNotificationResponse, + ); + + // Handle notification taps from PushService.kt (native FCM notifications). + // "openRoom" arrives when app is already running and user taps a notification. + _kInstallChannel.setMethodCallHandler((call) async { + final args = call.arguments; + if (call.method == 'openRoom') { + final roomId = (args is Map ? args['room_id'] : null) as String?; + print('[NOTIF-FG] openRoom: $roomId'); + if (roomId != null && roomId.isNotEmpty) { + ref.read(activeRoomIdProvider.notifier).state = roomId; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + } + await _sendPendingReply(ref); + } else if (call.method == 'replyFromNotification') { + // Native Android path: ReplyReceiver forwarded the inline reply text via + // MethodChannel — no Activity was opened. + final roomId = (args is Map ? args['room_id'] : null) as String?; + final text = (args is Map ? args['text'] : null) as String?; + print('[NOTIF-FG] replyFromNotification: room=$roomId text=$text'); + if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) { + _androidPlugin.cancel(_stableRoomId(roomId)); + Future storePending() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('notif_pending_reply_room', roomId); + await prefs.setString('notif_pending_reply_text', text); + } + + try { + final client = await ref.read(matrixClientProvider.future); + final room = client.getRoomById(roomId); + if (room != null) { + await room.sendTextEvent(text); + print('[NOTIF-FG] replyFromNotification: sent OK'); + } else { + print('[NOTIF-FG] replyFromNotification: room null, storing in prefs'); + await storePending(); + // Retry on the next sync — room list is populated after first sync. + client.onSync.stream.first + .then((_) => _sendPendingReply(ref)) + .catchError((_) {}); + } + } catch (e) { + print('[NOTIF-FG] replyFromNotification error: $e — storing in prefs'); + await storePending(); + // Resume-based retry (checkPendingReply in app.dart) will pick this up. + } + } + } else if (call.method == 'sharedContent') { + // "Teilen nach Pyramid" während die App läuft — AppShell zeigt den Picker. + final text = (args is Map ? args['text'] : null) as String?; + final paths = (args is Map ? args['paths'] : null); + final share = PendingShare( + text: text, + paths: paths is List ? paths.whereType().toList() : const [], + ); + if (!share.isEmpty) { + ref.read(pendingShareProvider.notifier).state = share; + } + } else if (call.method == 'decryptAndUpdateNotification') { + // Called by PushService.kt after showing a "Neue Nachricht" placeholder. + // We decrypt the event here (the Matrix client has the keys) and overwrite + // the notification with the real sender name and message body. + final roomId = (args is Map ? args['room_id'] : null) as String?; + final eventId = (args is Map ? args['event_id'] : null) as String?; + final notifId = (args is Map ? args['notif_id'] : null) as int?; + if (roomId != null && roomId.isNotEmpty) { + _decryptAndUpdateNotification(ref, roomId, eventId, notifId).catchError((_) {}); + } + } + }); + + // Cold-start tap: app was killed, user tapped notification → check launch intent. + try { + final roomId = await _kInstallChannel.invokeMethod('getInitialRoomId'); + if (roomId != null && roomId.isNotEmpty) { + ref.read(activeRoomIdProvider.notifier).state = roomId; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + } + } catch (_) {} + + // Cold-start reply: app was killed, user pressed Reply → reply text is in the + // launch intent (onCreate fires instead of onNewIntent when app is killed). + try { + final reply = await _kInstallChannel.invokeMethod('getInitialReply'); + if (reply is Map) { + final roomId = reply['room_id'] as String?; + final text = reply['text'] as String?; + print('[NOTIF-REPLY] getInitialReply: roomId=$roomId text=$text'); + if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) { + // Persist so _sendPendingReply sends it after the first sync. + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('notif_pending_reply_room', roomId); + await prefs.setString('notif_pending_reply_text', text); + // Send the app back to background immediately — the user replied from + // the notification and doesn't want the full UI to appear. + try { + await _kInstallChannel.invokeMethod('minimizeApp'); + } catch (_) {} + } + } + } catch (_) {} + + // Cold-start share: app was launched via "Teilen nach Pyramid". + try { + final share = await _kInstallChannel.invokeMethod('getInitialShare'); + if (share is Map) { + final text = share['text'] as String?; + final paths = share['paths']; + final pending = PendingShare( + text: text, + paths: paths is List ? paths.whereType().toList() : const [], + ); + if (!pending.isEmpty) { + ref.read(pendingShareProvider.notifier).state = pending; + } + } + } catch (_) {} + + final androidPlugin = _androidPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + + // Pre-create channel so it exists before the FCM background isolate runs. + await androidPlugin?.createNotificationChannel( + const AndroidNotificationChannel( + 'pyramid_messages', + 'Nachrichten', + description: 'Neue Nachrichten', + importance: Importance.high, + enableVibration: true, + playSound: true, + ), + ); + + final granted = await androidPlugin?.requestNotificationsPermission(); + if (granted == false) { + ref.read(notificationsBlockedProvider.notifier).state = true; + } + + // FCM: Firebase initialisieren + Pusher beim Homeserver registrieren. + // Use .future so we wait for the Matrix client to be fully ready + // rather than potentially getting null from .value during startup. + try { + final client = await ref.read(matrixClientProvider.future); + await initFcm(client); + // Rooms are only reliably in memory after the first sync completes. + // Send any reply that was stored while the app was killed. + client.onSync.stream.first + .then((_) => _sendPendingReply(ref)) + .catchError((_) {}); + } catch (_) {} +} + +// ─── IsolateNameServer message handler ─────────────────────────────────────── +// Called when a background isolate (handleBackgroundNotificationResponse) +// forwards a notification action to the running main isolate. + +void _onIsolateMessage(dynamic message, WidgetRef ref) { + if (message is! Map) return; + final actionId = message['actionId'] as String?; + final payload = message['payload'] as String?; + final input = message['input'] as String?; + final id = message['id'] as int?; + + print('[NOTIF-ISOLATE] action=$actionId payload=$payload input=$input'); + + switch (actionId) { + case 'reply': + final roomId = payload; + final text = (input ?? '').trim(); + if (roomId == null || roomId.isEmpty || text.isEmpty) return; + _androidPlugin.cancel(id ?? _stableRoomId(roomId)); + ref.read(matrixClientProvider.future).then((client) async { + final room = client.getRoomById(roomId); + if (room != null) { + try { + await room.sendTextEvent(text); + print('[NOTIF-ISOLATE] reply sent OK'); + } catch (e) { + print('[NOTIF-ISOLATE] sendTextEvent failed: $e — storing in prefs'); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('notif_pending_reply_room', roomId); + await prefs.setString('notif_pending_reply_text', text); + client.onSync.stream.first + .then((_) => _sendPendingReply(ref)) + .catchError((_) {}); + } + } else { + print('[NOTIF-ISOLATE] room not in memory — storing in prefs'); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('notif_pending_reply_room', roomId); + await prefs.setString('notif_pending_reply_text', text); + client.onSync.stream.first + .then((_) => _sendPendingReply(ref)) + .catchError((_) {}); + } + }); + + case 'read': + if (payload != null) { + _androidPlugin.cancel(_stableRoomId(payload)); + } else if (id != null) { + _androidPlugin.cancel(id); + } + print('[NOTIF-ISOLATE] read → notification dismissed'); + + default: + // Plain notification tap → navigate to room. + if (payload != null && payload.isNotEmpty) { + ref.read(activeRoomIdProvider.notifier).state = payload; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + print('[NOTIF-ISOLATE] tap → navigated to $payload'); + } + } +} + +/// Sends a reply stored by the background notification handler. +/// Called on cold start and on every app resume so no reply is ever lost. +Future _sendPendingReply(WidgetRef ref) async { + try { + final prefs = await SharedPreferences.getInstance(); + final roomId = prefs.getString('notif_pending_reply_room'); + final text = prefs.getString('notif_pending_reply_text'); + print('[NOTIF-REPLY] _sendPendingReply: roomId=$roomId text=$text'); + if (roomId == null || text == null || text.isEmpty) return; + + final client = await ref.read(matrixClientProvider.future); + print('[NOTIF-REPLY] client ready, rooms=${client.rooms.length}'); + + final room = client.getRoomById(roomId); + print('[NOTIF-REPLY] getRoomById → ${room != null ? 'found' : 'null'}'); + if (room == null) { + print('[NOTIF-REPLY] room not in memory yet, retrying after next sync'); + client.onSync.stream.first + .then((_) => _sendPendingReply(ref)) + .catchError((_) {}); + return; + } + + await room.sendTextEvent(text); + print('[NOTIF-REPLY] sendTextEvent OK, clearing prefs'); + await prefs.remove('notif_pending_reply_room'); + await prefs.remove('notif_pending_reply_text'); + } catch (e) { + print('[NOTIF-REPLY] error: $e — prefs kept for retry'); + } +} + +/// Opens the OS notification-settings screen for this app (Android only). +Future openNotificationSettings() async { + if (!Platform.isAndroid) return; + await _kInstallChannel.invokeMethod('openNotificationSettings'); +} + +/// Opens the system battery optimisation dialog for Pyramid (Android only). +/// Samsung and other OEMs kill FCM background processes unless this is set +/// to "Unrestricted". +Future openBatteryOptimizationSettings() async { + if (!Platform.isAndroid) return; + await _kInstallChannel.invokeMethod('openBatterySettings'); +} + +Future _initWindows() async { + await localNotifier.setup(appName: 'Pyramid'); +} + +// ─── Heartbeat — tells the FCM background handler the app is running ────────── + +bool _appInForeground = true; + +/// Call from the app lifecycle observer in app.dart. +void setAppForeground(bool value) { + _appInForeground = value; + if (!value && Platform.isAndroid) { + // Clear immediately so the FCM background handler runs as soon as the app + // is no longer in the foreground / is killed. + SharedPreferences.getInstance() + .then((p) => p.setInt('notif_app_heartbeat', 0)); + } +} + +void _pingHeartbeat() { + if (!Platform.isAndroid || !_appInForeground) return; + SharedPreferences.getInstance().then( + (p) => p.setInt('notif_app_heartbeat', DateTime.now().millisecondsSinceEpoch), + ); +} + +// ─── Reset notification timestamp on app resume ─────────────────────────────── +// Events that arrived while the app was backgrounded were already notified by +// the FCM background handler. Update _watchStartTime so the notification +// watcher skips those catch-up events when the app comes back to foreground. + +void resetNotificationStartTime() { + _watchStartTime = DateTime.now(); +} + +// ─── Cancel notification for a room (call when the chat is opened) ──────────── + +Future cancelNotificationForRoom(String roomId) async { + if (Platform.isAndroid && _androidInitialized) { + await _androidPlugin.cancel(_stableRoomId(roomId)); + } else if (Platform.isWindows) { + final notif = _windowsNotifs.remove(roomId); + notif?.close(); + } +} + +// ─── Decrypt & update notification (called by PushService via MethodChannel) ── +// PushService shows a "Neue Nachricht" placeholder immediately. This function +// is called while the app is backgrounded — the Matrix client has the E2EE keys +// so it can decrypt the event and overwrite the placeholder with real content. + +Future _decryptAndUpdateNotification( + WidgetRef ref, String roomId, String? eventId, int? notifId) async { + try { + final client = await ref + .read(matrixClientProvider.future) + .timeout(const Duration(seconds: 5)); + var room = client.getRoomById(roomId); + if (room == null) { + print('[NOTIF] decryptAndUpdate: room $roomId not found'); + return; + } + + // The event might not have synced yet — wait for the next sync (max 12 s). + // We use lastEvent as a proxy: after sync it will be the event we want. + Event? event = room.lastEvent; + final bool alreadyHave = + eventId == null || event?.eventId == eventId; + if (!alreadyHave || event == null) { + print('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId'); + await client.onSync.stream.first.timeout(const Duration(seconds: 12)); + room = client.getRoomById(roomId); + if (room == null) return; + event = room.lastEvent; + } + + if (event == null) { + print('[NOTIF] decryptAndUpdate: event still null after sync'); + return; + } + if (event.type != EventTypes.Message) return; + if (event.senderId == client.userID) return; + + final rawBody = event.body; + // If decryption failed the body starts with "** Unable to decrypt" + if (rawBody.isEmpty || rawBody.startsWith('**')) { + print('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder'); + return; + } + + final notifBody = + rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody; + final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId); + final displayName = sender.displayName ?? + event.senderId.split(':').first.replaceFirst('@', ''); + final title = room.isDirectChat + ? displayName + : '$displayName · ${room.getLocalizedDisplayname()}'; + + // Overwrite the "Neue Nachricht" placeholder — same notifId so Android + // replaces the existing notification rather than stacking a new one. + await _kInstallChannel.invokeMethod('showNativeNotification', { + 'title': title, + 'body': notifBody, + 'roomId': roomId, + 'notifId': notifId ?? _stableRoomId(roomId), + }); + print('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"'); + } catch (e) { + print('[NOTIF] decryptAndUpdate error: $e'); + } +} + +// ─── Show notification ──────────────────────────────────────────────────────── + +Future showMessageNotification({ + required String roomId, + String? roomName, + required String senderName, + required String body, +}) async { + final title = roomName != null ? '$senderName · $roomName' : senderName; + if (Platform.isAndroid && _androidInitialized) { + // Use native Kotlin notification so the reply PendingIntent targets + // ReplyReceiver directly — the app never opens when the user replies. + try { + await _kInstallChannel.invokeMethod('showNativeNotification', { + 'title': title, + 'body': body, + 'roomId': roomId, + 'notifId': _stableRoomId(roomId), + }); + } catch (e) { + print('[NOTIF] showNativeNotification failed: $e'); + } + } else if (Platform.isWindows) { + // Close the previous notification for this room before showing a new one. + _windowsNotifs.remove(roomId)?.close(); + + final notif = LocalNotification( + identifier: 'msg_$roomId', + title: title, + body: body, + ); + notif.onShow = () {}; + _windowsNotifs[roomId] = notif; + await localNotifier.notify(notif); + + // Also flash the taskbar button + WindowsTaskbar.setFlashTaskbarAppIcon(); + } +} + +// ─── Taskbar badge (Windows) ────────────────────────────────────────────────── + +bool _badgeVisible = false; + +Future _updateTaskbar(int unreadCount) async { + if (!Platform.isWindows) return; + try { + if (unreadCount > 0 && !_badgeVisible) { + _badgeVisible = true; + await WindowsTaskbar.setProgressMode(TaskbarProgressMode.error); + } else if (unreadCount == 0 && _badgeVisible) { + _badgeVisible = false; + await WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress); + } + } catch (_) {} +} + +// Exported so AppShell can call it when the app resumes from background. +Future checkPendingReply(WidgetRef ref) => _sendPendingReply(ref); + +// ─── Watcher provider ───────────────────────────────────────────────────────── + +final notificationWatcherProvider = Provider((ref) { + ref.watch(matrixClientProvider).whenData((client) { + _watchEvents(ref, client); + _watchBadge(client); + }); +}); + +// Set once on first _watchEvents call so we can skip catchup events from the +// initial SDK sync (they were already shown by the FCM background handler). +DateTime? _watchStartTime; + +void _watchEvents(Ref ref, Client client) { + _watchStartTime ??= DateTime.now(); + client.onNotification.stream.listen((Event event) { + if (event.type != EventTypes.Message) return; + if (event.senderId == client.userID) return; + + // Skip events that predate this app session — the FCM background handler + // already showed a notification for them while the app was killed. + if (event.originServerTs.isBefore(_watchStartTime!)) return; + + // ── Master notification toggle (desktop/Windows setting) ────────────── + if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + if (!ref.read(notifDesktopProvider)) return; + } + + final roomId = event.room.id; + // Suppress only when the window is focused AND the room is currently open. + // If the window is minimized or in the background, always show the notification. + final windowFocused = ref.read(windowFocusedProvider); + if (windowFocused && roomId == ref.read(activeRoomIdProvider)) return; + + final room = event.room; + if (room.membership != Membership.join) return; + + // ── Mention-only filter ─────────────────────────────────────────────── + if (ref.read(notifMentionOnlyDmProvider)) { + final myId = client.userID ?? ''; + // Check MSC3952 m.mentions field first, then fall back to body scan. + final mentionedIds = (event.content + .tryGetMap('m.mentions') + ?.tryGetList('user_ids')) ?? + []; + final bodyMentionsUs = event.body.contains(myId) || + event.body.contains('@${myId.split(':').first.replaceFirst('@', '')}'); + if (!mentionedIds.contains(myId) && !bodyMentionsUs) return; + } + + // ── Build notification body ─────────────────────────────────────────── + final rawBody = event.body; + final showPreview = ref.read(notifPreviewProvider); + final notifBody = showPreview + ? (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody) + : 'Neue Nachricht'; + + final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId); + final displayName = sender.displayName ?? + event.senderId.split(':').first.replaceFirst('@', ''); + + showMessageNotification( + roomId: roomId, + roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(), + senderName: displayName, + body: notifBody, + ); + }); +} + +void _watchBadge(Client client) { + client.onSync.stream.listen( + (_) { + try { + final total = client.rooms + .where((r) => r.membership == Membership.join) + .fold(0, (sum, r) => sum + r.notificationCount); + _updateTaskbar(total); + _pingHeartbeat(); // keep PushService.kt from double-notifying + } catch (_) {} + }, + onError: (_) {}, + cancelOnError: false, + ); +} diff --git a/lib/core/router.dart b/lib/core/router.dart index 58f4872..d887390 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -3,17 +3,14 @@ import 'package:go_router/go_router.dart'; import 'package:pyramid/features/auth/login_notifier.dart'; import 'package:pyramid/features/auth/login_page.dart'; import 'package:pyramid/features/auth/server_page.dart'; -import 'package:pyramid/features/chat/chat_page.dart'; -import 'package:pyramid/features/rooms/rooms_page.dart'; -import 'package:pyramid/features/settings/settings_page.dart'; -import 'package:pyramid/layout/shell_page.dart'; +import 'package:pyramid/layout/app_shell.dart'; final routerProvider = Provider((ref) { final isLoggedIn = ref.watch(isLoggedInProvider).valueOrNull ?? false; final hasServer = ref.watch(homeserverProvider) != null; return GoRouter( - initialLocation: '/rooms', + initialLocation: '/app', redirect: (context, state) { final loc = state.matchedLocation; final onAuth = loc == '/login' || loc == '/server'; @@ -24,7 +21,7 @@ final routerProvider = Provider((ref) { return null; } - if (isLoggedIn && onAuth) return '/rooms'; + if (isLoggedIn && onAuth) return '/app'; return null; }, routes: [ @@ -36,23 +33,9 @@ final routerProvider = Provider((ref) { path: '/login', builder: (context, state) => const LoginPage(), ), - ShellRoute( - builder: (context, state, child) => ShellPage(child: child), - routes: [ - GoRoute( - path: '/rooms', - builder: (context, state) => const RoomsPage(), - ), - GoRoute( - path: '/chat/:roomId', - builder: (context, state) => - ChatPage(roomId: state.pathParameters['roomId']!), - ), - GoRoute( - path: '/settings', - builder: (context, state) => const SettingsPage(), - ), - ], + GoRoute( + path: '/app', + builder: (context, state) => const AppShell(), ), ], ); diff --git a/lib/core/settings_prefs.dart b/lib/core/settings_prefs.dart new file mode 100644 index 0000000..7fd2ae4 --- /dev/null +++ b/lib/core/settings_prefs.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// ── Generic SharedPreferences-backed bool notifier ────────────────────────── + +class BoolPref extends StateNotifier { + final String _key; + + BoolPref(this._key, bool defaultValue) : super(defaultValue) { + _load(defaultValue); + } + + Future _load(bool def) async { + final p = await SharedPreferences.getInstance(); + if (mounted) state = p.getBool(_key) ?? def; + } + + Future set(bool v) async { + state = v; + final p = await SharedPreferences.getInstance(); + await p.setBool(_key, v); + } + + void toggle() => set(!state); +} + +// ── Generic SharedPreferences-backed String-Set notifier ─────────────────── + +class StringSetPref extends StateNotifier> { + final String _key; + + StringSetPref(this._key) : super({}) { + _load(); + } + + Future _load() async { + final p = await SharedPreferences.getInstance(); + final list = p.getStringList(_key); + if (mounted && list != null) state = list.toSet(); + } + + Future add(String v) async { + if (state.contains(v)) return; + state = {...state, v}; + final p = await SharedPreferences.getInstance(); + await p.setStringList(_key, state.toList()); + } + + Future remove(String v) async { + if (!state.contains(v)) return; + state = {...state}..remove(v); + final p = await SharedPreferences.getInstance(); + await p.setStringList(_key, state.toList()); + } +} + +// ── Notification preferences ───────────────────────────────────────────────── + +final notifDesktopProvider = + StateNotifierProvider((ref) => BoolPref('notif_desktop', true)); + +final notifSoundProvider = + StateNotifierProvider((ref) => BoolPref('notif_sound', true)); + +final notifPreviewProvider = + StateNotifierProvider((ref) => BoolPref('notif_preview', true)); + +final notifMentionOnlyDmProvider = + StateNotifierProvider((ref) => BoolPref('notif_mention_dm', false)); + +// ── Privacy preferences ────────────────────────────────────────────────────── + +final privacyPresenceProvider = + StateNotifierProvider((ref) => BoolPref('privacy_presence', true)); + +final privacyReadReceiptsProvider = + StateNotifierProvider((ref) => BoolPref('privacy_read_receipts', true)); + +final privacyTypingProvider = + StateNotifierProvider((ref) => BoolPref('privacy_typing', true)); + +// ── Theme persistence helpers ──────────────────────────────────────────────── + +Future saveThemePrefs({ + bool? isDark, + int? accentIdx, + double? radius, + double? motion, +}) async { + final p = await SharedPreferences.getInstance(); + if (isDark != null) await p.setBool('theme_dark', isDark); + if (accentIdx != null) await p.setInt('theme_accent', accentIdx); + if (radius != null) await p.setDouble('theme_radius', radius); + if (motion != null) await p.setDouble('theme_motion', motion); +} + +Future<({bool isDark, int accentIdx, double radius, double motion})> + loadThemePrefs() async { + final p = await SharedPreferences.getInstance(); + return ( + isDark: p.getBool('theme_dark') ?? true, + accentIdx: p.getInt('theme_accent') ?? 0, + radius: p.getDouble('theme_radius') ?? 12.0, + motion: p.getDouble('theme_motion') ?? 1.0, + ); +} + +// ── Server banner persistence ───────────────────────────────────────────────── +// Stored as a single JSON map under 'server_banners': homeserverUrl → mxcUri + +const _kServerBannersKey = 'server_banners'; + +Future> loadAllServerBanners() async { + final p = await SharedPreferences.getInstance(); + final raw = p.getString(_kServerBannersKey); + if (raw == null) return {}; + try { + final decoded = jsonDecode(raw) as Map; + return decoded.map((k, v) => MapEntry(k, v as String)); + } catch (_) { + return {}; + } +} + +Future saveServerBanner(String homeserverUrl, String? mxcUri) async { + final p = await SharedPreferences.getInstance(); + final current = await loadAllServerBanners(); + if (mxcUri == null || mxcUri.isEmpty) { + current.remove(homeserverUrl); + } else { + current[homeserverUrl] = mxcUri; + } + await p.setString(_kServerBannersKey, jsonEncode(current)); +} diff --git a/lib/core/theme.dart b/lib/core/theme.dart index 60f07d1..b7d1574 100644 --- a/lib/core/theme.dart +++ b/lib/core/theme.dart @@ -1,24 +1,151 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -final colorSchemeProvider = Provider((ref) { - return ColorScheme.fromSeed( - seedColor: const Color(0xFF6B4FA0), - brightness: Brightness.light, - ); -}); +// Pyramid design tokens — matching mockup CSS variables exactly +class PyramidColors { + const PyramidColors._(); -ThemeData buildTheme(ColorScheme colorScheme, Brightness brightness) { - final scheme = brightness == Brightness.dark - ? ColorScheme.fromSeed( - seedColor: const Color(0xFF6B4FA0), - brightness: Brightness.dark, - ) - : colorScheme; + // Dark theme + static const bg0 = Color(0xFF0A0A0C); + static const bg1 = Color(0xFF111114); + static const bg2 = Color(0xFF17171C); + static const bg3 = Color(0xFF1E1E25); + static const bgHover = Color(0xFF232330); + static const bgActive = Color(0xFF2A2A38); + static const border = Color(0xFF25252E); + static const borderStrong = Color(0xFF32323D); + static const fg = Color(0xFFECECF0); + static const fgMuted = Color(0xFFA4A4B0); + static const fgDim = Color(0xFF6F6F7D); + + // Light theme + static const bg0Light = Color(0xFFF5F4F0); + static const bg1Light = Color(0xFFFFFFFF); + static const bg2Light = Color(0xFFFAF9F5); + static const bg3Light = Color(0xFFF0EEEA); + static const bgHoverLight = Color(0xFFE9E7E1); + static const bgActiveLight = Color(0xFFDFDCD3); + static const borderLight = Color(0xFFE5E2DC); + static const borderStrongLight = Color(0xFFD3CFC6); + static const fgLight = Color(0xFF1A1A1F); + static const fgMutedLight = Color(0xFF54545E); + static const fgDimLight = Color(0xFF8A8A92); + + // Semantic colors (same in both themes) + static const danger = Color(0xFFE5484D); + static const success = Color(0xFF30A46C); + static const online = Color(0xFF4ADE80); + static const away = Color(0xFFFACC15); + static const busy = Color(0xFFF87171); + + // Accent presets — default: amber hsl(42 95% 58%) + static const accentAmber = Color(0xFFF5A614); + static const accentCoral = Color(0xFFED6951); + static const accentMint = Color(0xFF3DB88A); + static const accentViolet = Color(0xFFB67EEA); + static const accentCobalt = Color(0xFF5BA3F5); + static const accentLime = Color(0xFF8EC63F); + + static const accentFg = Color(0xFF0B0B0D); +} + +class PyramidTheme extends InheritedWidget { + final bool isDark; + final Color accent; + final double radius; + final double density; + final double motion; + + const PyramidTheme({ + super.key, + required super.child, + this.isDark = true, + this.accent = PyramidColors.accentAmber, + this.radius = 12, + this.density = 1, + this.motion = 1, + }); + + static PyramidTheme of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()!; + } + + Color get bg0 => isDark ? PyramidColors.bg0 : PyramidColors.bg0Light; + Color get bg1 => isDark ? PyramidColors.bg1 : PyramidColors.bg1Light; + Color get bg2 => isDark ? PyramidColors.bg2 : PyramidColors.bg2Light; + Color get bg3 => isDark ? PyramidColors.bg3 : PyramidColors.bg3Light; + Color get bgHover => isDark ? PyramidColors.bgHover : PyramidColors.bgHoverLight; + Color get bgActive => isDark ? PyramidColors.bgActive : PyramidColors.bgActiveLight; + Color get border => isDark ? PyramidColors.border : PyramidColors.borderLight; + Color get borderStrong => isDark ? PyramidColors.borderStrong : PyramidColors.borderStrongLight; + Color get fg => isDark ? PyramidColors.fg : PyramidColors.fgLight; + Color get fgMuted => isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight; + Color get fgDim => isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight; + Color get danger => PyramidColors.danger; + Color get online => PyramidColors.online; + Color get away => PyramidColors.away; + Color get busy => PyramidColors.busy; + Color get accentSoft => accent.withAlpha(36); // ~14% + Color get accentGlow => accent.withAlpha(89); // ~35% + Color get accentFg => PyramidColors.accentFg; + + double get rSm => radius * 0.6; + double get rBase => radius; + double get rLg => radius * 1.5; + double get rXl => radius * 2; + + double get rowPadY => 10 * density; + double get rowPadX => 12 * density; + double get msgGap => 16 * density; + + Duration get durationFast => const Duration(milliseconds: 150); + Duration get durationNormal => const Duration(milliseconds: 220); + Curve get springBounce => Curves.easeOutBack; + Curve get springSoft => Curves.easeOutCubic; + + @override + bool updateShouldNotify(PyramidTheme oldWidget) => + isDark != oldWidget.isDark || + accent != oldWidget.accent || + radius != oldWidget.radius || + density != oldWidget.density || + motion != oldWidget.motion; +} + +// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download. +TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color); + +ThemeData buildPyramidThemeData(bool isDark) { + final fg = isDark ? PyramidColors.fg : PyramidColors.fgLight; + final bg1 = isDark ? PyramidColors.bg1 : PyramidColors.bg1Light; + final accent = PyramidColors.accentAmber; return ThemeData( - colorScheme: scheme, useMaterial3: true, - dividerTheme: const DividerThemeData(space: 1, thickness: 1), + fontFamily: 'Inter', + brightness: isDark ? Brightness.dark : Brightness.light, + scaffoldBackgroundColor: bg1, + colorScheme: ColorScheme( + brightness: isDark ? Brightness.dark : Brightness.light, + primary: accent, + onPrimary: PyramidColors.accentFg, + secondary: accent, + onSecondary: PyramidColors.accentFg, + error: PyramidColors.danger, + onError: Colors.white, + surface: bg1, + onSurface: fg, + ), + textTheme: TextTheme( + bodyMedium: _base(fg).copyWith(fontSize: 14), + bodySmall: _base(fg).copyWith(fontSize: 12, color: isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight), + labelSmall: _base(fg).copyWith(fontSize: 11, color: isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight), + ), + dividerTheme: DividerThemeData( + color: isDark ? PyramidColors.border : PyramidColors.borderLight, + thickness: 1, + space: 1, + ), + splashFactory: NoSplash.splashFactory, + highlightColor: Colors.transparent, ); } diff --git a/lib/core/update_checker.dart b/lib/core/update_checker.dart index f48ac27..8883284 100644 --- a/lib/core/update_checker.dart +++ b/lib/core/update_checker.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; @@ -55,7 +56,27 @@ bool _isNewer((int, int, int) remote, (int, int, int) local) { return remote.$3 > local.$3; } -String? _findDownloadUrl(List assets) { +// Rewrite the host in a Gitea asset URL to always use the configured base, +// so downloads work from outside the home network once nginx is set up. +String? _rewriteHost(String? url) { + if (url == null) return null; + return url.replaceFirst(RegExp(r'https?://[^/]+'), _kGiteaBase); +} + +// Device ABIs in preference order (e.g. [arm64-v8a, armeabi-v7a, armeabi]), +// fetched once from the platform. Used to pick the matching split APK. +Future> _deviceAbis() async { + if (!Platform.isAndroid) return const []; + try { + final abis = await const MethodChannel('chat.pyramid.pyramid/install') + .invokeListMethod('getAbis'); + return abis?.map((a) => a.toLowerCase()).toList() ?? const []; + } catch (_) { + return const []; + } +} + +String? _findDownloadUrl(List assets, List abis) { if (assets.isEmpty) return null; final list = assets.cast>(); @@ -71,6 +92,12 @@ String? _findDownloadUrl(List assets) { return pick((n) => n.endsWith('.exe')) ?? pick((n) => n.endsWith('.msix')); } if (Platform.isAndroid) { + // Prefer the split APK matching the device's best ABI, then weaker ABIs. + // Fall back to any APK so fat-APK releases (pre-split) keep working. + for (final abi in abis) { + final url = pick((n) => n.endsWith('.apk') && n.contains(abi)); + if (url != null) return url; + } return pick((n) => n.endsWith('.apk')); } return null; @@ -78,38 +105,53 @@ String? _findDownloadUrl(List assets) { // ─── Fetch ──────────────────────────────────────────────────────────────────── -Future _fetchLatestRelease() async { +Future _fetchLatestRelease(List abis) async { + // Fetch enough releases to find the highest version even if they were + // created out of chronological order (Gitea sorts by created_at, not semver). + final uri = Uri.parse( + '$_kGiteaBase/api/v1/repos/$_kGiteaRepo/releases?limit=20', + ); + // Let network/timeout exceptions propagate so the provider shows an error + // state instead of silently appearing as "up to date". + final response = await http + .get(uri, headers: {'Accept': 'application/json'}) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + throw Exception('Gitea antwortete mit ${response.statusCode}'); + } + 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; + // Find the release with the highest version number, ignoring drafts and + // pre-releases. This is robust even when releases are created out of order. + UpdateInfo? best; + for (final item in list) { + final data = item as Map; + if (data['draft'] == true || data['prerelease'] == true) continue; - final remoteVer = _parseVersion(tagName); - if (remoteVer == null) return null; + final tagName = data['tag_name'] as String? ?? ''; + if (tagName.isEmpty) continue; - final assets = (data['assets'] as List?) ?? []; - final downloadUrl = _findDownloadUrl(assets); + final remoteVer = _parseVersion(tagName); + if (remoteVer == null) continue; - 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, - ); + if (best == null || _isNewer(remoteVer, best.remoteVersion)) { + final assets = (data['assets'] as List?) ?? []; + final downloadUrl = _rewriteHost(_findDownloadUrl(assets, abis)); + best = UpdateInfo( + tagName: tagName, + releaseName: data['name'] as String? ?? tagName, + body: data['body'] as String? ?? '', + htmlUrl: '$_kGiteaBase/$_kGiteaRepo/releases/tag/$tagName', + remoteVersion: remoteVer, + downloadUrl: downloadUrl, + ); + } + } + return best; } catch (_) { return null; } @@ -128,7 +170,7 @@ final updateInfoProvider = FutureProvider((ref) async { await prefs.setInt(_kPrefLastCheck, DateTime.now().millisecondsSinceEpoch); - final remote = await _fetchLatestRelease(); + final remote = await _fetchLatestRelease(await _deviceAbis()); if (remote == null) return null; final info = await PackageInfo.fromPlatform(); diff --git a/lib/core/voip_manager.dart b/lib/core/voip_manager.dart new file mode 100644 index 0000000..826bd19 --- /dev/null +++ b/lib/core/voip_manager.dart @@ -0,0 +1,536 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; +import 'package:matrix/matrix.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:webrtc_interface/webrtc_interface.dart'; +import 'package:pyramid/core/ice_servers.dart'; +import 'package:pyramid/widgets/screen_share_picker.dart'; + +class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate { + static PyramidVoipManager? _instance; + static PyramidVoipManager get instance => _instance!; + + final Client client; + late final VoIP voip; + CallSession? currentCall; + + final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer(); + final rtc.RTCVideoRenderer remoteRenderer = rtc.RTCVideoRenderer(); + bool _renderersInitialized = false; + + bool _isMicMuted = false; + bool _isCameraMuted = true; + bool _isScreensharing = false; + bool _isRemoteScreensharing = false; + bool _isDeafened = false; + bool _isUpdating = false; + String? _selectedSourceId; + String _currentQualityKey = 'hd'; + + bool get isMicMuted => _isMicMuted; + bool get isCameraMuted => _isCameraMuted; + bool get isScreensharing => _isScreensharing; + bool get isRemoteScreensharing => _isRemoteScreensharing; + bool get isDeafened => _isDeafened; + String get currentQualityKey => _currentQualityKey; + + PyramidVoipManager(this.client) { + _instance = this; + voip = VoIP(client, this); + // Suppress speakerphone call — not supported on Windows/Desktop + try { + (voip as dynamic).onConfigSpeakerphone = (bool _) async {}; + } catch (_) {} + _ensureRenderers(); + } + + Future _ensureRenderers() async { + if (_renderersInitialized) return; + try { + await localRenderer.initialize(); + await remoteRenderer.initialize(); + _renderersInitialized = true; + } catch (e) { + print('[VOIP] Renderer init error: $e'); + } + } + + @override + MediaDevices get mediaDevices => MediaDevicesWrapper(rtc.navigator.mediaDevices, this); + + @override + bool get isWeb => kIsWeb; + + @override + Future createPeerConnection( + Map configuration, [ + Map constraints = const {}, + ]) async { + // Zusätzliche ICE-Server (STUN/TURN aus ice.json) zu den vom Homeserver + // gelieferten mergen — ohne STUN scheitern Calls außerhalb des LANs, + // weil der Server hinter CGNAT hängt. + try { + final extra = await IceServers.get(); + final merged = Map.from(configuration); + final existing = + (merged['iceServers'] as List?)?.cast>() ?? []; + merged['iceServers'] = [...existing, ...extra]; + configuration = merged; + } catch (e) { + print('[VOIP] ICE server merge error: $e'); + } + return rtc.createPeerConnection(configuration, constraints); + } + + @override + Future handleNewCall(CallSession call) async { + print('[VOIP] Incoming call ${call.callId} (type: ${call.type})'); + await _ensureRenderers(); + currentCall = call; + _isCameraMuted = call.type == CallType.kVoice; + _setupCallListeners(call); + _updateInternalStates(); + notifyListeners(); + } + + Timer? _renderRefreshTimer; + void _setupCallListeners(CallSession call) { + call.onCallStateChanged.stream.listen((state) { + print('[VOIP] State: $state'); + _updateInternalStates(); + + if (state == CallState.kConnected) { + if (!_isCameraMuted) { + print('[VOIP] Ensuring camera is unmuted on connect'); + call.setLocalVideoMuted(false); + } + } + _updateRenderers(); + }); + + _renderRefreshTimer?.cancel(); + _renderRefreshTimer = Timer.periodic( + const Duration(milliseconds: 1000), // Faster polling to catch feeds + (_) => _updateRenderers(), + ); + + _updateRenderers(); + } + + void _updateInternalStates() { + if (currentCall == null) return; + final dynamic c = currentCall; + try { + _isMicMuted = c.isMicrophoneMuted == true; + _isCameraMuted = c.isLocalVideoMuted == true; + } catch (_) {} + notifyListeners(); + } + + Future _updateRenderers() async { + if (currentCall == null || _isUpdating) return; + _isUpdating = true; + + try { + await _ensureRenderers(); + final dynamic c = currentCall; + rtc.MediaStream? remote; + rtc.MediaStream? local; + + rtc.MediaStream? extract(dynamic obj) { + if (obj == null) return null; + if (obj is rtc.MediaStream) return obj; + try { return obj.stream as rtc.MediaStream?; } catch (_) {} + return null; + } + + final List feeds = []; + try { + final dynamic sdkFeeds = c.getFeeds(); + if (sdkFeeds is Iterable) feeds.addAll(sdkFeeds.toList()); + } catch (_) {} + + bool remoteScreenFound = false; + bool localScreenFound = false; + + for (final dynamic f in feeds) { + final s = extract(f); + if (s == null) continue; + + bool isLocal = false; + try { isLocal = f.isLocal == true || f.userId == client.userID; } catch (_) {} + + String purpose = 'm.usermedia'; + try { purpose = f.purpose ?? 'm.usermedia'; } catch (_) {} + + if (isLocal) { + if (local == null || purpose == 'm.screenshare') { + local = s; + if (purpose == 'm.screenshare') localScreenFound = true; + } + } else { + if (remote == null || purpose == 'm.screenshare') { + remote = s; + if (purpose == 'm.screenshare') remoteScreenFound = true; + } + } + } + + _isScreensharing = localScreenFound; + _isRemoteScreensharing = remoteScreenFound; + + if (remote == null) { + try { remote = extract(c.remoteUserMediaStream); } catch (_) {} + } + if (remote == null) { + try { remote = extract(c.remoteStream); } catch (_) {} + } + if (local == null) { + try { local = extract(c.localUserMediaStream); } catch (_) {} + } + + // Ausgabelautstärke (Settings-Slider) auf den Remote-Stream anwenden — + // nur einmal pro Stream, _updateRenderers läuft auch per Timer. + if (remote != null && remote.id != _volumeAppliedStreamId) { + _volumeAppliedStreamId = remote.id; + _remoteAudioStream = remote; + applyOutputVolume(); + } + + // Final check for tracks before assigning + bool changed = false; + if (remote != null && remote.getVideoTracks().isNotEmpty) { + if (remoteRenderer.srcObject?.id != remote.id) { + print('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)'); + remoteRenderer.srcObject = remote; + changed = true; + } + } else if (remoteRenderer.srcObject != null) { + remoteRenderer.srcObject = null; + changed = true; + } + + if (local != null && local.getVideoTracks().isNotEmpty) { + if (localRenderer.srcObject?.id != local.id) { + print('[VOIP] Assigning Local Renderer: ${local.id} (${local.getVideoTracks().length} video tracks)'); + localRenderer.srcObject = local; + changed = true; + } + } else if (localRenderer.srcObject != null) { + localRenderer.srcObject = null; + changed = true; + } + + if (changed) notifyListeners(); + } catch (e) { + print('[VOIP] Error in _updateRenderers: $e'); + } finally { + _isUpdating = false; + } + } + + @override + Future handleCallEnded(CallSession session) async { + if (currentCall?.callId == session.callId) { + _renderRefreshTimer?.cancel(); + _renderRefreshTimer = null; + localRenderer.srcObject = null; + remoteRenderer.srcObject = null; + _remoteAudioStream = null; + _volumeAppliedStreamId = null; + currentCall = null; + _isScreensharing = false; + _isRemoteScreensharing = false; + notifyListeners(); + } + } + + // ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ───────────── + rtc.MediaStream? _remoteAudioStream; + String? _volumeAppliedStreamId; + + /// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den + /// Remote-Audio-Stream an. Ohne [value] wird der Pref-Wert gelesen. + Future applyOutputVolume([double? value]) async { + var v = value; + if (v == null) { + try { + final prefs = await SharedPreferences.getInstance(); + v = prefs.getDouble('voice_output_volume') ?? 1.0; + } catch (_) { + v = 1.0; + } + } + final stream = _remoteAudioStream; + if (stream == null) return; + for (final track in stream.getAudioTracks()) { + try { + await rtc.Helper.setVolume(v.clamp(0.0, 2.0), track); + } catch (_) {} + } + } + + @override + Future handleMissedCall(CallSession session) async { + if (currentCall?.callId == session.callId) { + currentCall = null; + notifyListeners(); + } + } + + @override + Future handleNewGroupCall(GroupCallSession groupCall) async {} + @override + Future handleGroupCallEnded(GroupCallSession groupCall) async {} + @override + bool get canHandleNewCall => currentCall == null; + @override + Future playRingtone() async {} + @override + Future stopRingtone() async {} + @override + EncryptionKeyProvider? get keyProvider => null; + @override + Future registerListeners(CallSession session) async {} + + Future safeAction(Future Function() action) async { + try { + await action(); + await Future.delayed(const Duration(milliseconds: 1000)); + } catch (e) { + print('[VOIP] Action error: $e'); + } + _updateInternalStates(); + _updateRenderers(); + } + + Future toggleMic() async { + if (currentCall == null) return; + _isMicMuted = !_isMicMuted; + notifyListeners(); + try { + await currentCall!.setMicrophoneMuted(_isMicMuted); + } catch (e) { + _isMicMuted = !_isMicMuted; + notifyListeners(); + } + } + + Future toggleCamera() async { + if (currentCall == null) return; + _isCameraMuted = !_isCameraMuted; + notifyListeners(); + try { + await currentCall!.setLocalVideoMuted(_isCameraMuted); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration(milliseconds: 300 + (i * 200))); + await _updateRenderers(); + } + } catch (e) { + print('[VOIP] toggleCamera error: $e'); + _isCameraMuted = !_isCameraMuted; + notifyListeners(); + } + _updateInternalStates(); + await _updateRenderers(); + } + + Future toggleScreenSharing([BuildContext? context]) async { + if (currentCall == null) return; + final call = currentCall!; + + if (_isScreensharing) { + await safeAction(() => call.setScreensharingEnabled(false)); + return; + } + + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && context != null) { + final selectedSource = await ScreenSharePicker.show(context); + if (selectedSource == null) return; + _selectedSourceId = selectedSource.id; + } else { + _selectedSourceId = null; + } + + await safeAction(() async { + await call.setScreensharingEnabled(true); + }); + } + + Future toggleDeafen() async { + _isDeafened = !_isDeafened; + notifyListeners(); + } + + Future hangup() async { + if (currentCall == null) return; + await safeAction(() async { + await currentCall!.hangup(reason: CallErrorCode.userHangup); + currentCall = null; + }); + } + + Future startCall(String roomId, {bool video = false}) async { + final room = client.getRoomById(roomId); + if (room == null) return; + await _ensureRenderers(); + _isCameraMuted = !video; + + print('[VOIP] Starting call in room $roomId (video=$video)'); + final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice); + currentCall = call; + + _setupCallListeners(call); + + if (video) { + await Future.delayed(const Duration(milliseconds: 800)); + await call.setLocalVideoMuted(false); + await _updateRenderers(); + } + + _updateInternalStates(); + notifyListeners(); + } + + Future changeQuality(String key) async { + _currentQualityKey = key; + if (currentCall == null) { + notifyListeners(); + return; + } + + if (!_isCameraMuted) { + await toggleCamera(); + await Future.delayed(const Duration(milliseconds: 300)); + await toggleCamera(); + } + + if (_isScreensharing) { + final String? oldSource = _selectedSourceId; + await safeAction(() => currentCall!.setScreensharingEnabled(false)); + await Future.delayed(const Duration(milliseconds: 300)); + _selectedSourceId = oldSource; + await safeAction(() => currentCall!.setScreensharingEnabled(true)); + } + + notifyListeners(); + } +} + +class MediaDevicesWrapper extends MediaDevices { + final MediaDevices _delegate; + final PyramidVoipManager _manager; + MediaDevicesWrapper(this._delegate, this._manager); + + @override + Future getUserMedia(Map constraints) async { + final Map improvedConstraints = Map.from(constraints); + + final String? sourceId = _manager._selectedSourceId; + if (sourceId != null) { + if (improvedConstraints['video'] is! Map) { + improvedConstraints['video'] = {}; + } + + final v = Map.from(improvedConstraints['video']); + v['mandatory'] ??= {}; + v['mandatory']['chromeMediaSource'] = 'desktop'; + v['mandatory']['chromeMediaSourceId'] = sourceId; + improvedConstraints['video'] = v; + } + + // In den Einstellungen gewählte Geräte (voice_*_device Prefs) anwenden — + // nur wenn kein Screenshare-Source gesetzt ist (sonst nicht anfassen). + try { + final prefs = await SharedPreferences.getInstance(); + final mic = prefs.getString('voice_mic_device'); + final cam = prefs.getString('voice_camera_device'); + final speaker = prefs.getString('voice_speaker_device'); + if (mic != null && + improvedConstraints['audio'] != null && + improvedConstraints['audio'] != false) { + final a = improvedConstraints['audio'] is Map + ? Map.from(improvedConstraints['audio']) + : {}; + a['deviceId'] = mic; + a['optional'] = [ + ...?(a['optional'] as List?), + {'sourceId': mic}, + ]; + improvedConstraints['audio'] = a; + } + if (cam != null && + sourceId == null && + improvedConstraints['video'] != null && + improvedConstraints['video'] != false) { + final v = improvedConstraints['video'] is Map + ? Map.from(improvedConstraints['video']) + : {}; + v['deviceId'] = cam; + improvedConstraints['video'] = v; + } + if (speaker != null) { + rtc.Helper.selectAudioOutput(speaker).catchError((_) {}); + } + } catch (_) {} + + if (improvedConstraints['video'] != null && improvedConstraints['video'] != false) { + final String q = _manager.currentQualityKey; + int width = 1280; + int height = 720; + + if (q == 'sd') { width = 640; height = 360; } + else if (q == 'fhd') { width = 1920; height = 1080; } + else if (q == '4k') { width = 3840; height = 2160; } + + if (improvedConstraints['video'] is bool) { + improvedConstraints['video'] = { + 'width': {'ideal': width}, + 'height': {'ideal': height}, + 'frameRate': {'ideal': 30}, + }; + } else if (improvedConstraints['video'] is Map) { + final v = Map.from(improvedConstraints['video']); + v['width'] = {'ideal': width}; + v['height'] = {'ideal': height}; + improvedConstraints['video'] = v; + } + } + return _delegate.getUserMedia(improvedConstraints); + } + + @override + Future getDisplayMedia(Map constraints) async { + final String? sourceId = _manager._selectedSourceId; + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) { + final desktopConstraints = { + 'video': { + 'mandatory': { + 'chromeMediaSource': 'desktop', + 'chromeMediaSourceId': sourceId, + }, + 'optional': [], + }, + 'audio': false, + }; + return _delegate.getDisplayMedia(desktopConstraints); + } + return _delegate.getDisplayMedia(constraints); + } + + @override + Future> enumerateDevices() => _delegate.enumerateDevices(); + @override + MediaTrackSupportedConstraints getSupportedConstraints() => _delegate.getSupportedConstraints(); + @override + set ondevicechange(Function(dynamic event)? callback) => _delegate.ondevicechange = callback; + @override + Function(dynamic event)? get ondevicechange => _delegate.ondevicechange; + @override + Future> getSources() => _delegate.getSources(); + @override + Future selectAudioOutput([AudioOutputOptions? options]) => _delegate.selectAudioOutput(options); +} diff --git a/lib/features/auth/bootstrap_dialog.dart b/lib/features/auth/bootstrap_dialog.dart new file mode 100644 index 0000000..ce08ae7 --- /dev/null +++ b/lib/features/auth/bootstrap_dialog.dart @@ -0,0 +1,534 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/theme.dart'; + +Future showBootstrapDialog( + BuildContext context, + Client client, { + bool force = false, +}) async { + if (client.encryption == null) return; + if (!force) { + final enc = client.encryption!; + final cached = await enc.keyManager.isCached().catchError((_) => false); + if (cached && enc.crossSigning.enabled) return; + } + if (!context.mounted) return; + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _BootstrapDialog(client: client), + ); +} + +class _BootstrapDialog extends StatefulWidget { + final Client client; + const _BootstrapDialog({required this.client}); + + @override + State<_BootstrapDialog> createState() => _BootstrapDialogState(); +} + +class _BootstrapDialogState extends State<_BootstrapDialog> { + Bootstrap? _bootstrap; + final _keyCtrl = TextEditingController(); + bool _keyLoading = false; + String? _keyError; + bool _wipe = false; + + @override + void initState() { + super.initState(); + _start(); + } + + @override + void dispose() { + _keyCtrl.dispose(); + super.dispose(); + } + + Future _start() async { + final client = widget.client; + await client.roomsLoading; + await client.accountDataLoading; + await client.userDeviceKeysLoading; + while (client.prevBatch == null) { + await client.onSyncStatus.stream.first; + } + if (!mounted) return; + setState(() { + _bootstrap = client.encryption!.bootstrap( + onUpdate: (_) { + if (mounted) setState(() {}); + }, + ); + }); + } + + void _autoAdvance(Bootstrap bs) { + switch (bs.state) { + case BootstrapState.askWipeSsss: + WidgetsBinding.instance + .addPostFrameCallback((_) => bs.wipeSsss(_wipe)); + case BootstrapState.askBadSsss: + WidgetsBinding.instance + .addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); + case BootstrapState.askUseExistingSsss: + WidgetsBinding.instance + .addPostFrameCallback((_) => bs.useExistingSsss(!_wipe)); + case BootstrapState.askUnlockSsss: + // Handled by UI if oldSsssKeys exist, otherwise skip + WidgetsBinding.instance + .addPostFrameCallback((_) => bs.unlockedSsss()); + case BootstrapState.askNewSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss()); + case BootstrapState.askWipeCrossSigning: + WidgetsBinding.instance + .addPostFrameCallback((_) => bs.wipeCrossSigning(_wipe)); + case BootstrapState.askSetupCrossSigning: + WidgetsBinding.instance.addPostFrameCallback( + (_) => bs.askSetupCrossSigning( + setupMasterKey: true, + setupSelfSigningKey: true, + setupUserSigningKey: true, + ), + ); + case BootstrapState.askWipeOnlineKeyBackup: + WidgetsBinding.instance + .addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(_wipe)); + case BootstrapState.askSetupOnlineKeyBackup: + WidgetsBinding.instance + .addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true)); + default: + break; + } + } + + Future _unlock() async { + final bs = _bootstrap; + if (bs == null || bs.newSsssKey == null) return; + final key = _keyCtrl.text.trim(); + if (key.isEmpty) { + setState(() => _keyError = 'Bitte Wiederherstellungsschlüssel eingeben.'); + return; + } + setState(() { + _keyLoading = true; + _keyError = null; + }); + try { + await bs.newSsssKey!.unlock(keyOrPassphrase: key); + Logs().d('[Bootstrap] SSSS key unlocked'); + await bs.openExistingSsss(); + Logs().d('[Bootstrap] SSSS opened, secrets cached'); + if (bs.encryption.crossSigning.enabled) { + await widget.client.encryption!.crossSigning.selfSign(recoveryKey: key); + Logs().d('[Bootstrap] Cross-signing self-signed'); + } + } on InvalidPassphraseException catch (_) { + setState( + () => _keyError = 'Falscher Schlüssel. Bitte nochmal prüfen.', + ); + } on FormatException catch (_) { + setState(() => _keyError = 'Ungültiges Format. Prüfe deinen Schlüssel.'); + } catch (e) { + setState(() => _keyError = e.toString().split('\n').first); + } finally { + if (mounted) setState(() => _keyLoading = false); + } + } + + Future _close({required bool success}) async { + if (success) { + // Download all megolm session keys from backup — bypasses + // _requestedSessionIds deduplication so previously failed decryptions + // get a second chance. + widget.client.encryption?.keyManager + .loadAllKeys() + .catchError((e) => Logs().e('[Bootstrap] loadAllKeys failed', e)); + } + if (mounted) Navigator.of(context).pop(success); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final bs = _bootstrap; + + // Waiting for bootstrap to initialise + if (bs == null || bs.state == BootstrapState.loading) { + return _Shell( + pt: pt, + title: 'Verschlüsselung einrichten', + onClose: () => _close(success: false), + child: const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator.adaptive(), + ), + ), + ); + } + + // User must enter recovery key + if (bs.state == BootstrapState.openExistingSsss) { + return _Shell( + pt: pt, + title: 'Nachrichten entschlüsseln', + onClose: () => _close(success: false), + child: _KeyInputBody( + pt: pt, + ctrl: _keyCtrl, + loading: _keyLoading, + error: _keyError, + onUnlock: _unlock, + onWipe: () { + setState(() { + _wipe = true; + _start(); + }); + }, + ), + ); + } + + // Success — all done + if (bs.state == BootstrapState.done) { + return _Shell( + pt: pt, + title: 'Einrichtung abgeschlossen', + onClose: null, + child: _DoneBody( + pt: pt, + onClose: () => _close(success: true), + ), + ); + } + + // Error + if (bs.state == BootstrapState.error) { + return _Shell( + pt: pt, + title: 'Fehler', + onClose: () => _close(success: false), + child: _ErrorBody( + pt: pt, + onClose: () => _close(success: false), + ), + ); + } + + // Auto-advance all other (non-interactive) states + _autoAdvance(bs); + return _Shell( + pt: pt, + title: 'Verschlüsselung einrichten…', + onClose: () => _close(success: false), + child: const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator.adaptive(), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sub-widgets +// ───────────────────────────────────────────────────────────────────────────── + +class _Shell extends StatelessWidget { + final PyramidTheme pt; + final String title; + final VoidCallback? onClose; + final Widget child; + + const _Shell({ + required this.pt, + required this.title, + required this.onClose, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: pt.bg1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rBase + 4), + ), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 460, + minWidth: 320, + minHeight: 120, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 12, 12), + child: Row( + children: [ + Icon(Icons.shield_outlined, size: 18, color: pt.accent), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: TextStyle( + color: pt.fg, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + if (onClose != null) + IconButton( + icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), + onPressed: onClose, + tooltip: 'Überspringen', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + ), + ], + ), + ), + Divider(color: pt.border, height: 1), + child, + ], + ), + ), + ); + } +} + +class _KeyInputBody extends StatelessWidget { + final PyramidTheme pt; + final TextEditingController ctrl; + final bool loading; + final String? error; + final VoidCallback onUnlock; + final VoidCallback onWipe; + + const _KeyInputBody({ + required this.pt, + required this.ctrl, + required this.loading, + required this.error, + required this.onUnlock, + required this.onWipe, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Gib deinen Wiederherstellungsschlüssel ein, um deine verschlüsselten Nachrichten lesbar zu machen.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + const SizedBox(height: 16), + TextField( + controller: ctrl, + readOnly: loading, + autofocus: true, + autocorrect: false, + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontFamily: 'monospace', + ), + decoration: InputDecoration( + hintText: 'EsXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 12), + labelText: 'Wiederherstellungsschlüssel', + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + errorText: error, + errorMaxLines: 2, + filled: true, + fillColor: pt.bg2, + prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: pt.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: pt.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: pt.accent), + ), + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + cursorColor: pt.accent, + onSubmitted: (_) => onUnlock(), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 0, + ), + onPressed: loading ? null : onUnlock, + icon: loading + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + color: pt.accentFg, + strokeWidth: 2, + ), + ) + : const Icon(Icons.lock_open_outlined, size: 18), + label: Text( + loading ? 'Entschlüssele…' : 'Nachrichten entschlüsseln', + ), + ), + ), + const SizedBox(height: 12), + // Divider + lost key option + Row( + children: [ + Expanded(child: Divider(color: pt.border)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text('oder', style: TextStyle(color: pt.fgDim, fontSize: 12)), + ), + Expanded(child: Divider(color: pt.border)), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: PyramidColors.danger, + side: BorderSide(color: PyramidColors.danger.withAlpha(100)), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: loading ? null : onWipe, + icon: const Icon(Icons.delete_outlined, size: 16), + label: const Text('Schlüssel verloren — neu einrichten', style: TextStyle(fontSize: 13)), + ), + ), + ], + ), + ); + } +} + +class _DoneBody extends StatelessWidget { + final PyramidTheme pt; + final VoidCallback onClose; + + const _DoneBody({required this.pt, required this.onClose}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle_outline_rounded, + color: PyramidColors.success, + size: 56, + ), + const SizedBox(height: 12), + Text( + 'Alle Schlüssel wurden erfolgreich importiert.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 6), + Text( + 'Deine verschlüsselten Nachrichten werden jetzt im Hintergrund entschlüsselt.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, + ), + onPressed: onClose, + child: const Text('Fertig'), + ), + ), + ], + ), + ); + } +} + +class _ErrorBody extends StatelessWidget { + final PyramidTheme pt; + final VoidCallback onClose; + + const _ErrorBody({required this.pt, required this.onClose}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48), + const SizedBox(height: 12), + Text( + 'Etwas ist schiefgelaufen. Versuche es erneut.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: pt.fg, + side: BorderSide(color: pt.border), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: onClose, + child: const Text('Schließen'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/auth/login_notifier.dart b/lib/features/auth/login_notifier.dart index 6696c71..1b778ff 100644 --- a/lib/features/auth/login_notifier.dart +++ b/lib/features/auth/login_notifier.dart @@ -1,5 +1,9 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart' as mx; +import 'package:pyramid/core/fcm_push_service.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -74,6 +78,8 @@ class LoginNotifier extends StateNotifier { initialDeviceDisplayName: deviceName ?? 'Pyramid', ); + if (Platform.isAndroid) unawaited(initFcm(client)); + state = state.copyWith(status: LoginStatus.idle); } on mx.MatrixException catch (e) { state = state.copyWith( diff --git a/lib/features/call/mini_call_widget.dart b/lib/features/call/mini_call_widget.dart new file mode 100644 index 0000000..a3e5114 --- /dev/null +++ b/lib/features/call/mini_call_widget.dart @@ -0,0 +1,470 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/core/livekit_call_manager.dart'; +import 'package:pyramid/core/voip_manager.dart'; +import 'package:pyramid/widgets/screen_share_picker.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; +import 'package:livekit_client/livekit_client.dart'; + +class MiniCallWidget extends ConsumerStatefulWidget { + final dynamic call; // Can be LiveKitCallManager or PyramidVoipManager + const MiniCallWidget({super.key, required this.call}); + + @override + ConsumerState createState() => _MiniCallWidgetState(); +} + +class _MiniCallWidgetState extends ConsumerState { + int _elapsed = 0; + Timer? _timer; + bool _streamCollapsed = false; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() => _elapsed++); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + String _fmt(int s) { + final m = s ~/ 60; + final sec = s % 60; + return '${m.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final isVoip = widget.call is PyramidVoipManager; + final call = widget.call; + + final String displayName = isVoip + ? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unknown') + : call.displayName; + + final bool isConnecting = isVoip ? false : call.isConnecting; + final bool isMuted = isVoip ? call.isMicMuted : call.isMuted; + final bool isCameraOff = isVoip ? call.isCameraMuted : call.isCameraOff; + final bool isDeafened = isVoip ? call.isDeafened : call.isDeafened; + final bool isStreaming = isVoip ? call.isScreensharing : call.isScreenSharing; + + // Show stream preview if camera is on or screensharing + final showPreview = !isCameraOff || isStreaming; + // Im Querformat auf Mobilgeräten ist die Höhe knapp — Vorschau kompakter. + final compact = MediaQuery.sizeOf(context).height < 500; + final previewHeight = _streamCollapsed ? 28.0 : (compact ? 84.0 : 140.0); + + return Container( + decoration: BoxDecoration( + color: pt.bg2, + border: Border(top: BorderSide(color: pt.border)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Stream Mini Preview (Mockup style) + if (showPreview) + AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + height: previewHeight, + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.black, + border: Border(bottom: BorderSide(color: Colors.black26)), + ), + child: Stack( + children: [ + // Stream Content + if (!_streamCollapsed) + Positioned.fill( + child: isVoip + ? rtc.RTCVideoView( + // When we're screensharing show our local stream, + // otherwise show the remote participant's stream + isStreaming ? call.localRenderer : call.remoteRenderer, + objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + ) + : _LiveKitPreview(manager: call as LiveKitCallManager), + ), + + // Top bar of preview + Container( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black.withAlpha(200), Colors.transparent], + ), + ), + child: Row( + children: [ + const _LiveDot(), + const SizedBox(width: 6), + Expanded( + child: Text( + isStreaming ? 'STREAMING' : 'CAMERA ON', + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.5), + ), + ), + GestureDetector( + onTap: () => setState(() => _streamCollapsed = !_streamCollapsed), + child: Icon( + _streamCollapsed ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded, + size: 16, + color: Colors.white70, + ), + ), + ], + ), + ), + ], + ), + ), + + // Connected info row + GestureDetector( + onTap: () => ref.read(viewModeProvider.notifier).state = ViewMode.voice, + child: Container( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + pt.online.withAlpha(46), + Colors.transparent, + ], + end: Alignment.centerRight, + ), + border: Border(bottom: BorderSide(color: pt.border)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const _LiveDot(), + const SizedBox(width: 6), + Text( + isConnecting ? 'CONNECTING...' : 'VOICE CONNECTED', + style: TextStyle( + color: pt.online, + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + displayName, + style: TextStyle( + color: pt.fg, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + Text( + _fmt(_elapsed), + style: TextStyle( + color: pt.fgDim, + fontSize: 10, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ActionIcon( + icon: Icons.open_in_new_rounded, + pt: pt, + onTap: () => ref.read(viewModeProvider.notifier).state = ViewMode.voice, + ), + _ActionIcon( + icon: Icons.call_end_rounded, + pt: pt, + danger: true, + onTap: () { + if (isVoip) { + call.hangup(); + } else { + call.hangUp(); + } + ref.read(activeVoiceRoomIdProvider.notifier).state = null; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + }, + ), + ], + ), + ], + ), + ), + ), + // Controls + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + _ControlBtn( + icon: isMuted ? Icons.mic_off_rounded : Icons.mic_none_rounded, + active: !isMuted, + danger: isMuted, + pt: pt, + onTap: () { + if (isVoip) { + call.toggleMic(); + } else { + call.toggleMute(); + } + }, + ), + const SizedBox(width: 4), + _ControlBtn( + icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, + active: !isDeafened, + danger: isDeafened, + pt: pt, + onTap: () => call.toggleDeafen(), + ), + const SizedBox(width: 4), + _ControlBtn( + icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, + active: !isCameraOff, + pt: pt, + onTap: () => call.toggleCamera(), + ), + const SizedBox(width: 4), + _ControlBtn( + icon: Icons.screen_share_rounded, + active: isStreaming, + pt: pt, + onTap: () async { + if (isVoip) { + (call as PyramidVoipManager).toggleScreenSharing(context); + } else { + final lk = call as LiveKitCallManager; + if (lk.isScreenSharing) { + await lk.stopScreenShare(); + } else { + final source = await ScreenSharePicker.show(context); + if (source != null) { + await lk.startScreenShare(source.id); + } + } + } + }, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ActionIcon extends StatelessWidget { + final IconData icon; + final PyramidTheme pt; + final VoidCallback onTap; + final bool danger; + + const _ActionIcon({required this.icon, required this.pt, required this.onTap, this.danger = false}); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(icon, size: 18), + onPressed: onTap, + color: danger ? pt.danger : pt.fgDim, + visualDensity: VisualDensity.compact, + style: danger ? IconButton.styleFrom( + hoverColor: pt.danger.withAlpha(30), + ) : null, + ); + } +} + +class _ControlBtn extends StatefulWidget { + final IconData icon; + final bool active; + final bool danger; + final PyramidTheme pt; + final VoidCallback onTap; + + const _ControlBtn({required this.icon, required this.active, required this.pt, required this.onTap, this.danger = false}); + + @override + State<_ControlBtn> createState() => _ControlBtnState(); +} + +class _ControlBtnState extends State<_ControlBtn> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Expanded( + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: pt.durationFast, + height: 32, + decoration: BoxDecoration( + color: widget.active ? (widget.danger ? pt.danger : pt.accent) : pt.bg3, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Icon( + widget.icon, + size: 16, + color: widget.active ? (widget.danger ? Colors.white : pt.accentFg) : pt.fg, + ), + ), + ), + ), + ); + } +} + +class _LiveKitPreview extends StatelessWidget { + final LiveKitCallManager manager; + const _LiveKitPreview({required this.manager}); + + @override + Widget build(BuildContext context) { + final room = manager.room; + if (room == null) return const SizedBox(); + + // Find first available video track (screenshare preferred) + VideoTrack? bestTrack; + + // Check remote participants + for (final p in room.remoteParticipants.values) { + for (final pub in p.videoTrackPublications) { + if (pub.source == TrackSource.screenShareVideo) { + final t = pub.track; + if (t is VideoTrack) { + bestTrack = t; + break; + } + } + } + if (bestTrack != null) break; + } + + // Fallback to camera if no screen share + if (bestTrack == null) { + for (final p in room.remoteParticipants.values) { + for (final pub in p.videoTrackPublications) { + if (pub.source == TrackSource.camera) { + final t = pub.track; + if (t is VideoTrack) { + bestTrack = t; + break; + } + } + } + if (bestTrack != null) break; + } + } + + // Fallback to local if still nothing + if (bestTrack == null && room.localParticipant != null) { + for (final pub in room.localParticipant!.videoTrackPublications) { + final t = pub.track; + if (t is VideoTrack) { + bestTrack = t; + break; + } + } + } + + if (bestTrack == null) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.indigo.shade900, Colors.blue.shade900], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: const Center( + child: Icon(Icons.podcasts_rounded, color: Colors.white24, size: 40), + ), + ); + } + + return VideoTrackRenderer( + bestTrack, + fit: VideoViewFit.cover, + ); + } +} + +class _LiveDot extends StatefulWidget { + const _LiveDot(); + + @override + State<_LiveDot> createState() => _LiveDotState(); +} + +class _LiveDotState extends State<_LiveDot> with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2))..repeat(reverse: true); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return AnimatedBuilder( + animation: _ctrl, + builder: (context, _) => Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: pt.online, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: pt.online.withAlpha((_ctrl.value * 150).toInt()), + blurRadius: 4 * _ctrl.value, + spreadRadius: 2 * _ctrl.value, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/call/voice_channel.dart b/lib/features/call/voice_channel.dart new file mode 100644 index 0000000..4ffe4d1 --- /dev/null +++ b/lib/features/call/voice_channel.dart @@ -0,0 +1,1123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:collection/collection.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/widgets/screen_share_picker.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/core/livekit_call_manager.dart'; +import 'package:pyramid/core/voip_manager.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; +import 'package:pyramid/widgets/hover_region.dart'; +import 'package:pyramid/widgets/pyramid_loader.dart'; + +// Unified participant data to handle both LiveKit and VoIP +class _ParticipantData { + final String id; + final String name; + final Widget? videoWidget; + final bool isSpeaking; + final bool isMuted; + final bool hasVideo; + final bool isScreenShare; + final Color avatarColor; + final dynamic originalParticipant; + final double aspectRatio; + + const _ParticipantData({ + required this.id, + required this.name, + this.videoWidget, + this.isSpeaking = false, + this.isMuted = false, + this.hasVideo = false, + this.isScreenShare = false, + this.avatarColor = Colors.grey, + this.originalParticipant, + this.aspectRatio = 16.0 / 9.0, + }); +} + +class VoiceChannelView extends ConsumerWidget { + const VoiceChannelView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pt = PyramidTheme.of(context); + final call = ref.watch(callStateProvider); + final voip = ref.watch(voipStateProvider); + + if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) { + return _UnifiedCallView( + participants: _getVoipParticipants(voip), + isLiveKit: false, + displayName: voip.currentCall!.room.getLocalizedDisplayname(), + isConnecting: false, + onHangUp: () { + voip.hangup(); + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + }, + onMinimize: () => + ref.read(viewModeProvider.notifier).state = ViewMode.chat, + onToggleMic: voip.toggleMic, + onToggleDeafen: voip.toggleDeafen, + onToggleCam: voip.toggleCamera, + onToggleScreen: () => voip.toggleScreenSharing(context), + isMuted: voip.isMicMuted, + isDeafened: voip.isDeafened, + isCameraOff: voip.isCameraMuted, + isScreenSharing: voip.isScreensharing, + onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos), + pt: pt, + ); + } + + if (!call.isActive) { + return _NoCall(pt: pt, ref: ref); + } + + return _UnifiedCallView( + participants: _getLiveKitParticipants(call), + isLiveKit: true, + displayName: call.displayName, + isConnecting: call.isConnecting, + onHangUp: () { + call.hangUp(); + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + }, + onMinimize: () => + ref.read(viewModeProvider.notifier).state = ViewMode.chat, + onToggleMic: call.toggleMute, + onToggleDeafen: call.toggleDeafen, + onToggleCam: call.toggleCamera, + onToggleScreen: () => _handleLiveKitScreenShare(context, call), + isMuted: call.isMuted, + isDeafened: call.isDeafened, + isCameraOff: call.isCameraOff, + isScreenSharing: call.isScreenSharing, + onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos), + pt: pt, + ); + } + + List<_ParticipantData> _getLiveKitParticipants(LiveKitCallManager call) { + final room = call.room; + if (room == null) return []; + final all = [ + if (room.localParticipant != null) room.localParticipant!, + ...room.remoteParticipants.values, + ]; + + return all.mapIndexed((i, p) { + final screenPub = p.videoTrackPublications.firstWhereOrNull((v) => v.source == TrackSource.screenShareVideo); + final camPub = p.videoTrackPublications.firstWhereOrNull((v) => v.source == TrackSource.camera); + final videoTrack = (screenPub?.track ?? camPub?.track) as VideoTrack?; + final isScreen = screenPub != null; + + double ar = 16.0 / 9.0; + if (videoTrack != null) { + try { + final dim = (videoTrack as dynamic).currentDimensions ?? (videoTrack as dynamic).dimensions; + if (dim != null) { + final w = (dim.width as num?)?.toDouble() ?? 0; + final h = (dim.height as num?)?.toDouble() ?? 0; + if (w > 0 && h > 0) ar = w / h; + } + } catch (_) {} + } + + return _ParticipantData( + id: p.identity, + name: p.identity == room.localParticipant?.identity ? 'Du' : p.identity, + videoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.contain) : null, + isSpeaking: p.isSpeaking, + isMuted: !p.isMicrophoneEnabled(), + hasVideo: videoTrack != null, + isScreenShare: isScreen, + avatarColor: _avatarColors[i % _avatarColors.length], + originalParticipant: p, + aspectRatio: ar, + ); + }).toList(); + } + + List<_ParticipantData> _getVoipParticipants(PyramidVoipManager voip) { + final participants = <_ParticipantData>[]; + + double _ar(rtc.RTCVideoRenderer r) { + final w = r.videoWidth.toDouble(); + final h = r.videoHeight.toDouble(); + return (w > 0 && h > 0) ? w / h : 16.0 / 9.0; + } + + // Remote + final isRemoteScreen = voip.isRemoteScreensharing; + final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty; + participants.add(_ParticipantData( + id: 'remote', + name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote', + videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null, + isMuted: false, + hasVideo: hasRemoteVideo, + isScreenShare: isRemoteScreen, + avatarColor: _avatarColors[1], + aspectRatio: hasRemoteVideo ? _ar(voip.remoteRenderer) : 16.0 / 9.0, + )); + + // Local + final isLocalScreen = voip.isScreensharing; + final hasLocalVideo = voip.localRenderer.srcObject != null && voip.localRenderer.srcObject!.getVideoTracks().isNotEmpty; + participants.add(_ParticipantData( + id: 'local', + name: 'Du', + videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null, + isMuted: voip.isMicMuted, + hasVideo: hasLocalVideo, + isScreenShare: isLocalScreen, + avatarColor: _avatarColors[0], + aspectRatio: hasLocalVideo ? _ar(voip.localRenderer) : 16.0 / 9.0, + )); + + return participants; + } + + static const _avatarColors = [ + Color(0xFFFF6B9D), Color(0xFF06B6D4), Color(0xFFA855F7), + Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFFEF4444), + Color(0xFF3B82F6), Color(0xFF84CC16), + ]; +} + +class _UnifiedCallView extends StatefulWidget { + final List<_ParticipantData> participants; + final bool isLiveKit; + final String displayName; + final bool isConnecting; + final VoidCallback onHangUp; + final VoidCallback onMinimize; + final VoidCallback onToggleMic; + final VoidCallback onToggleDeafen; + final VoidCallback onToggleCam; + final VoidCallback onToggleScreen; + final bool isMuted; + final bool isDeafened; + final bool isCameraOff; + final bool isScreenSharing; + final Function(Offset)? onQualityMenu; + final PyramidTheme pt; + + const _UnifiedCallView({ + required this.participants, + required this.isLiveKit, + required this.displayName, + required this.isConnecting, + required this.onHangUp, + required this.onMinimize, + required this.onToggleMic, + required this.onToggleDeafen, + required this.onToggleCam, + required this.onToggleScreen, + required this.isMuted, + required this.isDeafened, + required this.isCameraOff, + required this.isScreenSharing, + this.onQualityMenu, + required this.pt, + }); + + @override + State<_UnifiedCallView> createState() => _UnifiedCallViewState(); +} + +class _UnifiedCallViewState extends State<_UnifiedCallView> { + String? _heroId; + bool _isGridView = false; + bool _stripCollapsed = false; + + @override + void initState() { + super.initState(); + _autoSelectHero(); + } + + @override + void didUpdateWidget(_UnifiedCallView oldWidget) { + super.didUpdateWidget(oldWidget); + _autoSelectHero(); + } + + void _autoSelectHero() { + if (_isGridView) return; + final screenshare = widget.participants.firstWhereOrNull((p) => p.isScreenShare); + if (screenshare != null && (_heroId == null || !widget.participants.any((p) => p.id == _heroId && p.hasVideo))) { + _heroId = screenshare.id; + } else if (_heroId == null && widget.participants.isNotEmpty) { + final firstVideo = widget.participants.firstWhereOrNull((p) => p.hasVideo); + if (firstVideo != null) _heroId = firstVideo.id; + } + } + + List<_ParticipantData> _sorted(List<_ParticipantData> all) { + int rank(_ParticipantData p) { + if (p.isScreenShare) return 0; + if (p.hasVideo) return 1; + if (p.isSpeaking) return 2; + return 3; + } + return [...all]..sort((a, b) => rank(a).compareTo(rank(b))); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final all = _sorted(widget.participants); + final hero = all.firstWhereOrNull((p) => p.id == _heroId) ?? (all.isNotEmpty ? all.first : null); + + if (widget.isConnecting) { + return Scaffold( + backgroundColor: pt.bg1, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const PyramidLoader(size: 80), + const SizedBox(height: 20), + Text('Verbinde…', + style: TextStyle(color: pt.fgMuted, fontSize: 14)), + const SizedBox(height: 16), + TextButton.icon( + style: TextButton.styleFrom(foregroundColor: pt.danger), + onPressed: widget.onHangUp, + icon: const Icon(Icons.call_end_rounded, size: 16), + label: const Text('Abbrechen'), + ), + ], + ), + ), + ); + } + + return Scaffold( + backgroundColor: pt.bg1, + body: Column( + children: [ + _CallHeader( + displayName: widget.displayName, + isGridView: _isGridView, + onToggleView: () => setState(() => _isGridView = !_isGridView), + onMinimize: widget.onMinimize, + pt: pt, + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: _isGridView || hero == null + ? _ParticipantGrid(participants: all, pt: pt, onSelect: (p) => setState(() { _heroId = p.id; _isGridView = false; })) + : _HeroLayout( + hero: hero, + others: all.where((p) => p.id != hero.id).toList(), + stripCollapsed: _stripCollapsed, + onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed), + onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v), + onSelectOther: (p) => setState(() => _heroId = p.id), + onQualityMenu: widget.onQualityMenu, + pt: pt, + ), + ), + ), + _CallControls( + isMuted: widget.isMuted, + isDeafened: widget.isDeafened, + isCameraOff: widget.isCameraOff, + isScreenSharing: widget.isScreenSharing, + onToggleMic: widget.onToggleMic, + onToggleDeafen: widget.onToggleDeafen, + onToggleCam: widget.onToggleCam, + onToggleScreen: widget.onToggleScreen, + onHangUp: widget.onHangUp, + pt: pt, + ), + ], + ), + ); + } +} + +class _CallHeader extends StatelessWidget { + final String displayName; + final bool isGridView; + final VoidCallback onToggleView; + final VoidCallback onMinimize; + final PyramidTheme pt; + + const _CallHeader({required this.displayName, required this.isGridView, required this.onToggleView, required this.onMinimize, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))), + child: Row( + children: [ + Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted), + const SizedBox(width: 8), + Text(displayName, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(width: 8), + _LiveBadge(pt: pt), + const Spacer(), + PyrIconBtn( + icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg), + tooltip: isGridView ? 'Hero mode' : 'Grid mode', + onPressed: onToggleView, + ), + const SizedBox(width: 4), + PyrIconBtn( + icon: Icon(Icons.close_fullscreen_rounded, color: pt.fg), + tooltip: 'Minimieren', + onPressed: onMinimize, + ), + ], + ), + ); + } +} + +class _LiveBadge extends StatelessWidget { + final PyramidTheme pt; + const _LiveBadge({required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(999), color: pt.online.withAlpha(30)), + child: Row(children: [ + Container(width: 6, height: 6, decoration: BoxDecoration(color: pt.online, shape: BoxShape.circle)), + const SizedBox(width: 4), + Text('Live', style: TextStyle(color: pt.online, fontSize: 11, fontWeight: FontWeight.w600)), + ]), + ); + } +} + +class _ParticipantGrid extends StatelessWidget { + final List<_ParticipantData> participants; + final PyramidTheme pt; + final Function(_ParticipantData) onSelect; + + const _ParticipantGrid({required this.participants, required this.pt, required this.onSelect}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, box) { + final cols = box.maxWidth > 900 ? 4 : (box.maxWidth > 600 ? 3 : 2); + const spacing = 12.0; + final tileWidth = (box.maxWidth - spacing * (cols - 1) - 24) / cols; + + return SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Wrap( + spacing: spacing, + runSpacing: spacing, + children: participants.map((p) => SizedBox( + width: tileWidth, + height: tileWidth / p.aspectRatio, + child: _ParticipantTile(data: p, pt: pt, onTap: () => onSelect(p)), + )).toList(), + ), + ); + }); + } +} + +class _HeroLayout extends StatelessWidget { + final _ParticipantData hero; + final List<_ParticipantData> others; + final bool stripCollapsed; + final VoidCallback onToggleStrip; + final Function(bool) onSetStripCollapsed; + final Function(_ParticipantData) onSelectOther; + final Function(Offset)? onQualityMenu; + final PyramidTheme pt; + + const _HeroLayout({ + required this.hero, + required this.others, + required this.stripCollapsed, + required this.onToggleStrip, + required this.onSetStripCollapsed, + required this.onSelectOther, + this.onQualityMenu, + required this.pt, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, box) { + final isLandscape = box.maxWidth > box.maxHeight && box.maxWidth > 600; + + final heroView = _HeroSection( + data: hero, + onQualityMenu: onQualityMenu, + pt: pt, + ); + + final strip = _ParticipantStrip( + participants: others, + isVertical: isLandscape, + onSelect: onSelectOther, + pt: pt, + ); + + if (isLandscape) { + return Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(12), + child: GestureDetector( + onHorizontalDragEnd: (details) { + if (details.primaryVelocity! < -300) onSetStripCollapsed(false); + if (details.primaryVelocity! > 300) onSetStripCollapsed(true); + }, + child: heroView, + ), + ), + ), + if (!stripCollapsed) + SizedBox(width: 200, child: Padding(padding: const EdgeInsets.only(right: 12, top: 12, bottom: 12), child: strip)), + _CollapseHandle(isVertical: true, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt), + ], + ); + } else { + return Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(12), + child: GestureDetector( + onVerticalDragEnd: (details) { + if (details.primaryVelocity! < -300) onSetStripCollapsed(false); + if (details.primaryVelocity! > 300) onSetStripCollapsed(true); + }, + child: heroView, + ), + ), + ), + _CollapseHandle(isVertical: false, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt), + if (!stripCollapsed) + SizedBox(height: 120, child: Padding(padding: const EdgeInsets.only(bottom: 12, left: 12, right: 12), child: strip)), + ], + ); + } + }); + } +} + +class _HeroSection extends StatefulWidget { + final _ParticipantData data; + final Function(Offset)? onQualityMenu; + final PyramidTheme pt; + + const _HeroSection({required this.data, this.onQualityMenu, required this.pt}); + + @override + State<_HeroSection> createState() => _HeroSectionState(); +} + +class _HeroSectionState extends State<_HeroSection> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: () => setState(() => _hovered = !_hovered), + child: Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(pt.rLg), + child: Container( + color: Colors.black, + child: Stack( + children: [ + Positioned.fill( + child: widget.data.videoWidget != null + ? _MirrorWrapper( + isLocal: widget.data.id == 'local' || (widget.data.originalParticipant is LocalParticipant), + isScreenShare: widget.data.isScreenShare, + child: widget.data.videoWidget!, + ) + : _BigAvatar(data: widget.data, pt: pt), + ), + + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + top: _hovered ? 0 : -60, left: 0, right: 0, + child: _HeroTopBar(data: widget.data, pt: pt), + ), + + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + bottom: _hovered ? 0 : -60, left: 0, right: 0, + child: _HeroBottomBar( + data: widget.data, + onQualityMenu: widget.onQualityMenu, + pt: pt, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _HeroTopBar extends StatelessWidget { + final _ParticipantData data; + final PyramidTheme pt; + const _HeroTopBar({required this.data, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black54, Colors.transparent]), + ), + child: Row( + children: [ + if (data.isScreenShare) _LivePill(pt: pt), + const SizedBox(width: 8), + Text( + data.isScreenShare ? "${data.name} präsentiert" : data.name, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14), + ), + ], + ), + ); + } +} + +class _HeroBottomBar extends StatelessWidget { + final _ParticipantData data; + final Function(Offset)? onQualityMenu; + final PyramidTheme pt; + const _HeroBottomBar({required this.data, this.onQualityMenu, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + gradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black54, Colors.transparent]), + ), + child: Row( + children: [ + const Spacer(), + if (onQualityMenu != null) + _HeroActionBtn(icon: Icons.settings_rounded, onTapDown: (d) => onQualityMenu!(d.globalPosition)), + const SizedBox(width: 8), + _HeroActionBtn( + icon: Icons.fullscreen_rounded, + onTap: () => Navigator.of(context).push(MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => _FullscreenVideoPage(data: data), + )), + ), + ], + ), + ); + } +} + +class _HeroActionBtn extends StatelessWidget { + final IconData icon; + final VoidCallback? onTap; + final Function(TapDownDetails)? onTapDown; + const _HeroActionBtn({required this.icon, this.onTap, this.onTapDown}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + onTapDown: onTapDown, + child: Container( + width: 36, height: 36, + decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(8)), + child: Icon(icon, color: Colors.white, size: 20), + ), + ); + } +} + +class _LivePill extends StatelessWidget { + final PyramidTheme pt; + const _LivePill({required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: const Color(0xFFEF4444), borderRadius: BorderRadius.circular(4)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 4, height: 4, decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle)), + const SizedBox(width: 4), + const Text('LIVE', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.w800, letterSpacing: 0.5)), + ]), + ); + } +} + +class _ParticipantStrip extends StatelessWidget { + final List<_ParticipantData> participants; + final bool isVertical; + final Function(_ParticipantData) onSelect; + final PyramidTheme pt; + + const _ParticipantStrip({required this.participants, required this.isVertical, required this.onSelect, required this.pt}); + + @override + Widget build(BuildContext context) { + return ListView.separated( + scrollDirection: isVertical ? Axis.vertical : Axis.horizontal, + itemCount: participants.length, + separatorBuilder: (_, __) => const SizedBox(width: 12, height: 12), + itemBuilder: (context, i) => SizedBox( + width: isVertical ? double.infinity : 101 * participants[i].aspectRatio, + height: 101, + child: _ParticipantTile(data: participants[i], pt: pt, onTap: () => onSelect(participants[i])), + ), + ); + } +} + +class _ParticipantTile extends StatefulWidget { + final _ParticipantData data; + final PyramidTheme pt; + final VoidCallback onTap; + + const _ParticipantTile({required this.data, required this.pt, required this.onTap}); + + @override + State<_ParticipantTile> createState() => _ParticipantTileState(); +} + +class _ParticipantTileState extends State<_ParticipantTile> with SingleTickerProviderStateMixin { + late AnimationController _speakingCtrl; + + @override + void initState() { + super.initState(); + _speakingCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 800))..repeat(reverse: true); + } + + @override + void dispose() { + _speakingCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final data = widget.data; + + return GestureDetector( + onTap: widget.onTap, + child: AnimatedBuilder( + animation: _speakingCtrl, + builder: (context, _) { + final isSpeaking = data.isSpeaking; + final pulse = isSpeaking ? _speakingCtrl.value : 0.0; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all( + color: isSpeaking + ? pt.accent.withAlpha((150 + pulse * 105).toInt()) + : pt.border, + width: isSpeaking ? 2 : 1, + ), + boxShadow: isSpeaking + ? [BoxShadow(color: pt.accent.withAlpha((20 + pulse * 40).toInt()), blurRadius: 4 + pulse * 8, spreadRadius: pulse * 2)] + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(pt.rBase - 1), + child: Stack( + children: [ + Positioned.fill( + child: data.videoWidget != null + ? _MirrorWrapper( + isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant), + isScreenShare: data.isScreenShare, + child: data.videoWidget!, + ) + : _SmallAvatar(data: data, pt: pt), + ), + + Positioned( + left: 6, bottom: 6, right: 6, + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(4)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (data.isMuted) ...[Icon(Icons.mic_off_rounded, size: 10, color: pt.danger), const SizedBox(width: 4)], + Flexible(child: Text(data.name, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis)), + ], + ), + ), + ], + ), + ), + if (data.isScreenShare) Positioned(top: 6, left: 6, child: _LivePill(pt: pt)), + ], + ), + ), + ); + }, + ), + ); + } +} + +class _CollapseHandle extends StatelessWidget { + final bool isVertical; + final bool collapsed; + final VoidCallback onToggle; + final PyramidTheme pt; + + const _CollapseHandle({required this.isVertical, required this.collapsed, required this.onToggle, required this.pt}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onToggle, + child: Container( + width: isVertical ? 24 : double.infinity, + height: isVertical ? double.infinity : 24, + color: Colors.transparent, + child: Center( + child: Icon( + isVertical + ? (collapsed ? Icons.chevron_left_rounded : Icons.chevron_right_rounded) + : (collapsed ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded), + color: pt.fgDim, size: 20, + ), + ), + ), + ); + } +} + +class _BigAvatar extends StatelessWidget { + final _ParticipantData data; + final PyramidTheme pt; + const _BigAvatar({required this.data, required this.pt}); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 100, height: 100, + decoration: BoxDecoration( + gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight), + shape: BoxShape.circle, + ), + child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))), + ), + ); + } +} + +class _SmallAvatar extends StatelessWidget { + final _ParticipantData data; + final PyramidTheme pt; + const _SmallAvatar({required this.data, required this.pt}); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 44, height: 44, + decoration: BoxDecoration( + gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight), + shape: BoxShape.circle, + ), + child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))), + ), + ); + } +} + +class _CallControls extends StatelessWidget { + final bool isMuted; + final bool isDeafened; + final bool isCameraOff; + final bool isScreenSharing; + final VoidCallback onToggleMic; + final VoidCallback onToggleDeafen; + final VoidCallback onToggleCam; + final VoidCallback onToggleScreen; + final VoidCallback onHangUp; + final PyramidTheme pt; + + const _CallControls({ + required this.isMuted, + required this.isDeafened, + required this.isCameraOff, + required this.isScreenSharing, + required this.onToggleMic, + required this.onToggleDeafen, + required this.onToggleCam, + required this.onToggleScreen, + required this.onHangUp, + required this.pt, + }); + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), + decoration: BoxDecoration(color: pt.bg1, border: Border(top: BorderSide(color: pt.border))), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic), + const SizedBox(width: 16), + _VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen), + const SizedBox(width: 16), + _VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam), + const SizedBox(width: 16), + _VoiceCtrlBtn(icon: Icons.screen_share_rounded, active: isScreenSharing, pt: pt, onTap: onToggleScreen), + const SizedBox(width: 24), + Container(width: 1, height: 32, color: pt.border), + const SizedBox(width: 24), + _VoiceCtrlBtn(icon: Icons.call_end_rounded, isDanger: true, pt: pt, onTap: onHangUp), + ], + ), + ), + ); + } +} + +class _NoCall extends StatelessWidget { + final PyramidTheme pt; + final WidgetRef ref; + const _NoCall({required this.pt, required this.ref}); + + @override + Widget build(BuildContext context) { + return Container( + color: pt.bg1, + child: Column( + children: [ + Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))), + child: Row( + children: [ + PyrIconBtn( + icon: const Icon(Icons.menu_rounded), + tooltip: 'Toggle sidebar', + onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s), + ), + const SizedBox(width: 8), + Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted), + const SizedBox(width: 8), + Text('No active call', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), + ], + ), + ), + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('𐃏', style: TextStyle(fontSize: 48, color: pt.fgDim.withAlpha(100))), + const SizedBox(height: 12), + Text('Join a voice room to start a call', style: TextStyle(color: pt.fgMuted, fontSize: 14)), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _FullscreenVideoPage extends StatelessWidget { + final _ParticipantData data; + const _FullscreenVideoPage({required this.data}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack(children: [ + Positioned.fill( + child: data.videoWidget != null + ? _MirrorWrapper( + isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant), + isScreenShare: data.isScreenShare, + child: data.videoWidget!, + ) + : _BigAvatar(data: data, pt: PyramidTheme.of(context)), + ), + Positioned( + top: 0, right: 0, + child: SafeArea( + child: IconButton( + icon: const Icon(Icons.fullscreen_exit_rounded, color: Colors.white, size: 28), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ), + ]), + ); + } +} + +void _handleLiveKitScreenShare(BuildContext context, LiveKitCallManager call) async { + if (call.isScreenSharing) { + await call.stopScreenShare(); + } else { + final source = await ScreenSharePicker.show(context); + if (source != null) await call.startScreenShare(source.id); + } +} + +void _showLiveKitQualityMenu(BuildContext context, LiveKitCallManager call, Offset pos) { + final pt = PyramidTheme.of(context); + showMenu( + context: context, + position: RelativeRect.fromLTRB(pos.dx - 210, pos.dy - 350, pos.dx, pos.dy), + color: pt.bg2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)), + items: [ + const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))), + for (final q in [('sd', 'SD', '360p'), ('hd', 'HD', '720p'), ('fhd', 'Full HD', '1080p'), ('4k', '4K', '2160p')]) + PopupMenuItem(onTap: () => call.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.currentQualityKey == q.$1, pt: pt)), + const PopupMenuDivider(), + const PopupMenuItem(enabled: false, height: 28, child: Text('EMPFANGEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))), + for (final q in [('auto', 'Auto', ''), ('high', 'Hoch', 'FHD'), ('medium', 'Mittel', 'HD'), ('low', 'Niedrig', 'SD')]) + PopupMenuItem(onTap: () => call.setSubscribeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.subscribeQualityKey == q.$1, pt: pt)), + ], + ); +} + +void _showVoipQualityMenu(BuildContext context, PyramidVoipManager voip, Offset pos) { + final pt = PyramidTheme.of(context); + showMenu( + context: context, + position: RelativeRect.fromLTRB(pos.dx - 210, pos.dy - 200, pos.dx, pos.dy), + color: pt.bg2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)), + items: [ + const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))), + for (final q in [('sd', 'SD', '360p'), ('hd', 'HD', '720p'), ('fhd', 'Full HD', '1080p'), ('4k', '4K', '2160p')]) + PopupMenuItem(onTap: () => voip.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: voip.currentQualityKey == q.$1, pt: pt)), + ], + ); +} + +class _QualityItem extends StatelessWidget { + final String label; + final String? hint; + final bool active; + final PyramidTheme pt; + const _QualityItem({required this.label, required this.active, required this.pt, this.hint}); + + @override + Widget build(BuildContext context) { + return Row(children: [ + if (active) Icon(Icons.check_rounded, size: 16, color: pt.accent) else const SizedBox(width: 16), + const SizedBox(width: 8), + Text(label, style: TextStyle(color: active ? pt.accent : pt.fg, fontSize: 13, fontWeight: active ? FontWeight.bold : FontWeight.normal)), + if (hint != null && hint!.isNotEmpty) ...[const Spacer(), Text(hint!, style: TextStyle(color: pt.fgMuted, fontSize: 11))], + ]); + } +} + +class _VoiceCtrlBtn extends StatefulWidget { + final IconData icon; + final PyramidTheme pt; + final bool active; + final bool danger; + final bool isDanger; + final VoidCallback? onTap; + + const _VoiceCtrlBtn({required this.icon, required this.pt, this.onTap, this.active = false, this.danger = false, this.isDanger = false}); + + @override + State<_VoiceCtrlBtn> createState() => _VoiceCtrlBtnState(); +} + +class _VoiceCtrlBtnState extends State<_VoiceCtrlBtn> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + Color bg; + Color iconColor; + if (widget.isDanger) { bg = pt.danger; iconColor = Colors.white; } + else if (widget.danger) { bg = pt.danger.withAlpha(30); iconColor = pt.danger; } + else if (widget.active) { bg = pt.accent.withAlpha(30); iconColor = pt.accent; } + else { bg = _hovered ? pt.bgHover : pt.bg3; iconColor = pt.fg; } + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 52, height: 52, + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(26)), + child: Center(child: Icon(widget.icon, size: 20, color: iconColor)), + ), + ), + ); + } +} + +class _MirrorWrapper extends StatelessWidget { + final Widget child; + final bool isLocal; + final bool isScreenShare; + + const _MirrorWrapper({required this.child, required this.isLocal, required this.isScreenShare}); + + @override + Widget build(BuildContext context) { + if (isLocal && !isScreenShare) { + return Transform( + alignment: Alignment.center, + transform: Matrix4.identity()..rotateY(3.14159), + child: child, + ); + } + return child; + } +} diff --git a/lib/features/chat/attachment_dialog.dart b/lib/features/chat/attachment_dialog.dart new file mode 100644 index 0000000..cfa052f --- /dev/null +++ b/lib/features/chat/attachment_dialog.dart @@ -0,0 +1,328 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:mime/mime.dart'; +import 'package:pyramid/core/theme.dart'; + +class AttachmentDialog extends StatefulWidget { + final List files; + final Room room; + final Event? replyTo; + + const AttachmentDialog({ + super.key, + required this.files, + required this.room, + this.replyTo, + }); + + static Future show( + BuildContext context, + List files, + Room room, { + Event? replyTo, + }) async { + // Snappy Scale+Fade-Eingangsanimation statt Standard-Einblendung. + return await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Anhang', + barrierColor: Colors.black54, + transitionDuration: const Duration(milliseconds: 180), + pageBuilder: (_, __, ___) => + AttachmentDialog(files: files, room: room, replyTo: replyTo), + transitionBuilder: (_, anim, __, child) { + final curved = + CurvedAnimation(parent: anim, curve: Curves.easeOutBack); + return FadeTransition( + opacity: anim, + child: ScaleTransition(scale: Tween(begin: 0.92, end: 1.0).animate(curved), child: child), + ); + }, + ) ?? + false; + } + + @override + State createState() => _AttachmentDialogState(); +} + +class _AttachmentDialogState extends State { + final _msgCtrl = TextEditingController(); + bool _sending = false; + String? _error; + bool _compress = false; + + bool get _hasImages => widget.files.any((f) { + final mime = lookupMimeType(f.path.split(Platform.pathSeparator).last) ?? ''; + return mime.startsWith('image/'); + }); + + @override + void dispose() { + _msgCtrl.dispose(); + super.dispose(); + } + + Future _send() async { + setState(() { _sending = true; _error = null; }); + try { + for (final file in widget.files) { + final bytes = await file.readAsBytes(); + final filename = file.path.split(Platform.pathSeparator).last; + final mimeType = lookupMimeType(filename) ?? 'application/octet-stream'; + final matrixFile = MatrixFile(bytes: bytes, name: filename, mimeType: mimeType); + final isImage = mimeType.startsWith('image/'); + await widget.room.sendFileEvent( + matrixFile, + inReplyTo: widget.replyTo, + shrinkImageMaxDimension: (_compress && isImage) ? 1600 : null, + ); + } + if (_msgCtrl.text.trim().isNotEmpty) { + await widget.room.sendTextEvent( + _msgCtrl.text.trim(), + inReplyTo: widget.replyTo, + ); + } + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + setState(() { _sending = false; _error = e.toString().split('\n').first; }); + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + + return Dialog( + backgroundColor: pt.bg1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rLg), + side: BorderSide(color: pt.border), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480, minWidth: 320), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Row( + children: [ + Icon(Icons.attach_file_rounded, size: 18, color: pt.accent), + const SizedBox(width: 8), + Text( + '${widget.files.length} Datei${widget.files.length == 1 ? '' : 'en'} senden', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), + ), + const Spacer(), + GestureDetector( + onTap: () => Navigator.of(context).pop(false), + child: Icon(Icons.close_rounded, size: 18, color: pt.fgDim), + ), + ], + ), + const SizedBox(height: 16), + // File list + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 160), + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.files.length, + itemBuilder: (ctx, i) => _FilePreview(file: widget.files[i], pt: pt), + ), + ), + const SizedBox(height: 16), + // Caption field + TextField( + controller: _msgCtrl, + style: TextStyle(color: pt.fg, fontSize: 14), + cursorColor: pt.accent, + maxLines: 3, + minLines: 1, + decoration: InputDecoration( + hintText: 'Nachricht hinzufügen (optional)', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 14), + filled: true, + fillColor: pt.bg2, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rBase), + borderSide: BorderSide(color: pt.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rBase), + borderSide: BorderSide(color: pt.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rBase), + borderSide: BorderSide(color: pt.accent), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + isDense: true, + ), + ), + // Image quality toggle + if (_hasImages) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + child: Row( + children: [ + Icon(Icons.high_quality_outlined, size: 16, color: pt.fgMuted), + const SizedBox(width: 8), + Text('Hohe Qualität', style: TextStyle(color: pt.fg, fontSize: 13)), + const Spacer(), + Switch.adaptive( + value: !_compress, + onChanged: (v) => setState(() => _compress = !v), + activeColor: pt.accent, + ), + ], + ), + ), + ], + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: TextStyle(color: pt.danger, fontSize: 12)), + ], + const SizedBox(height: 16), + // Actions + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _sending ? null : () => Navigator.of(context).pop(false), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _sending ? null : _send, + style: FilledButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rBase), + ), + ), + icon: _sending + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: pt.accentFg, + ), + ) + : const Icon(Icons.send_rounded, size: 14), + label: const Text('Senden'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _FilePreview extends StatefulWidget { + final File file; + final PyramidTheme pt; + + const _FilePreview({required this.file, required this.pt}); + + @override + State<_FilePreview> createState() => _FilePreviewState(); +} + +class _FilePreviewState extends State<_FilePreview> { + int? _bytes; + + @override + void initState() { + super.initState(); + widget.file.length().then((b) { if (mounted) setState(() => _bytes = b); }); + } + + @override + Widget build(BuildContext context) { + final filename = widget.file.path.split(Platform.pathSeparator).last; + final mime = lookupMimeType(filename) ?? 'application/octet-stream'; + final isImage = mime.startsWith('image/'); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: widget.pt.bg2, + borderRadius: BorderRadius.circular(widget.pt.rBase), + border: Border.all(color: widget.pt.border), + ), + child: Row( + children: [ + if (isImage) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.file(widget.file, width: 40, height: 40, fit: BoxFit.cover), + ) + else + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: widget.pt.bg3, + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Icon(_mimeIcon(mime), size: 20, color: widget.pt.fgDim), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + filename, + style: TextStyle(color: widget.pt.fg, fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + if (_bytes != null) + Text( + _formatSize(_bytes!), + style: TextStyle(color: widget.pt.fgDim, fontSize: 11), + ), + ], + ), + ), + ], + ), + ); + } + + IconData _mimeIcon(String mime) { + if (mime.startsWith('video/')) return Icons.videocam_outlined; + if (mime.startsWith('audio/')) return Icons.audio_file_outlined; + if (mime.contains('pdf')) return Icons.picture_as_pdf_outlined; + if (mime.contains('zip') || mime.contains('tar') || mime.contains('gz')) { + return Icons.folder_zip_outlined; + } + return Icons.insert_drive_file_outlined; + } + + String _formatSize(int bytes) { + if (bytes < 1024) return '${bytes} B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } +} diff --git a/lib/features/chat/chat_composer.dart b/lib/features/chat/chat_composer.dart new file mode 100644 index 0000000..b65bcee --- /dev/null +++ b/lib/features/chat/chat_composer.dart @@ -0,0 +1,630 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pyramid/core/settings_prefs.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/features/chat/attachment_dialog.dart'; +import 'package:pyramid/features/chat/emoji_picker.dart'; +import 'package:pyramid/features/chat/gif_sticker_picker.dart'; +import 'package:pyramid/utils/clipboard_image.dart'; + +class ChatComposer extends ConsumerStatefulWidget { + final Room room; + final Event? replyTo; + final VoidCallback? onClearReply; + final VoidCallback? onOpenEmoji; + + const ChatComposer({ + super.key, + required this.room, + this.replyTo, + this.onClearReply, + this.onOpenEmoji, + }); + + @override + ConsumerState createState() => _ChatComposerState(); +} + +class _ChatComposerState extends ConsumerState { + final _ctrl = TextEditingController(); + late final FocusNode _focus; + bool _canSend = false; + OverlayEntry? _gifOverlay; + OverlayEntry? _emojiOverlay; + Timer? _typingTimer; + bool _isTyping = false; + + @override + void initState() { + super.initState(); + _focus = FocusNode(onKeyEvent: _handleKeyEvent); + _ctrl.addListener(_onTextChanged); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.keyV && + HardwareKeyboard.instance.isControlPressed) { + final bytes = getClipboardImageBytes(); + if (bytes != null) { + _pasteClipboardImage(bytes); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + } + + Future _pasteClipboardImage(Uint8List bytes) async { + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/paste_${DateTime.now().millisecondsSinceEpoch}.png'); + await file.writeAsBytes(bytes); + if (!mounted) return; + await AttachmentDialog.show(context, [file], widget.room, replyTo: widget.replyTo); + widget.onClearReply?.call(); + file.delete().catchError((_) => file); + } + + /// Bilder, die die Android-Tastatur einfügt (Gboard-Clipboard-Chip, + /// GIF-Auswahl der Tastatur etc.) — landen im Anhang-Dialog mit Vorschau. + Future _handleInsertedContent(KeyboardInsertedContent content) async { + final bytes = content.data; + if (bytes == null || bytes.isEmpty) return; + final ext = content.mimeType.contains('/') + ? content.mimeType.split('/').last + : 'png'; + final dir = await getTemporaryDirectory(); + final file = File( + '${dir.path}/keyboard_${DateTime.now().millisecondsSinceEpoch}.$ext'); + await file.writeAsBytes(bytes); + if (!mounted) return; + await AttachmentDialog.show(context, [file], widget.room, replyTo: widget.replyTo); + widget.onClearReply?.call(); + file.delete().catchError((_) => file); + } + + void _onTextChanged() { + final has = _ctrl.text.trim().isNotEmpty; + if (has != _canSend) setState(() => _canSend = has); + _updateTyping(has); + } + + void _updateTyping(bool typing) { + final sendTyping = ref.read(privacyTypingProvider); + if (!sendTyping) return; + + if (typing) { + // Reset the auto-stop timer on every keystroke. + _typingTimer?.cancel(); + _typingTimer = Timer(const Duration(seconds: 7), () => _sendTyping(false)); + if (!_isTyping) _sendTyping(true); + } else { + _typingTimer?.cancel(); + if (_isTyping) _sendTyping(false); + } + } + + void _sendTyping(bool typing) { + _isTyping = typing; + widget.room + .setTyping(typing, timeout: typing ? 10000 : null) + .catchError((_) {}); + } + + @override + void dispose() { + _typingTimer?.cancel(); + if (_isTyping) widget.room.setTyping(false).catchError((_) {}); + _gifOverlay?.remove(); + _emojiOverlay?.remove(); + _ctrl.dispose(); + _focus.dispose(); + super.dispose(); + } + + void _toggleGifPicker() { + if (_gifOverlay != null) { + _gifOverlay!.remove(); + _gifOverlay = null; + return; + } + + final platform = Theme.of(context).platform; + final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; + + if (isMobile) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(ctx).bottom, + top: 40, + ), + child: GifStickerPicker( + room: widget.room, + onClose: () => Navigator.of(ctx).pop(), + ), + ), + ); + return; + } + + final box = context.findRenderObject() as RenderBox?; + final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero; + final size = box?.size ?? Size.zero; + + _gifOverlay = OverlayEntry(builder: (ctx) { + final screenSize = MediaQuery.sizeOf(ctx); + const pickerW = 380.0; + const pickerH = 460.0; + final left = (offset.dx + size.width - pickerW).clamp(8.0, screenSize.width - pickerW - 8); + final top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8); + return Material( + type: MaterialType.transparency, + child: Stack(children: [ + GestureDetector( + onTap: () { _gifOverlay?.remove(); _gifOverlay = null; }, + behavior: HitTestBehavior.translucent, + child: const SizedBox.expand(), + ), + Positioned( + left: left, + top: top, + child: GifStickerPicker( + room: widget.room, + onClose: () { _gifOverlay?.remove(); _gifOverlay = null; }, + ), + ), + ]), + ); + }); + Overlay.of(context).insert(_gifOverlay!); + } + + void _toggleEmojiPicker() { + if (_emojiOverlay != null) { + _emojiOverlay!.remove(); + _emojiOverlay = null; + return; + } + + final platform = Theme.of(context).platform; + final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; + + void onEmojiSelected(String emoji, BuildContext? ctx) { + final pos = _ctrl.selection.baseOffset; + final text = _ctrl.text; + final before = pos < 0 ? text : text.substring(0, pos); + final after = pos < 0 ? '' : text.substring(pos); + _ctrl.value = TextEditingValue( + text: '$before$emoji$after', + selection: TextSelection.collapsed( + offset: before.length + emoji.length, + ), + ); + if (isMobile && ctx != null) { + Navigator.of(ctx).pop(); + } else { + _emojiOverlay?.remove(); + _emojiOverlay = null; + } + _focus.requestFocus(); + } + + if (isMobile) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(ctx).bottom, + top: 40, + ), + child: EmojiPicker( + onEmojiSelected: (emoji) => onEmojiSelected(emoji, ctx), + onClose: () => Navigator.of(ctx).pop(), + ), + ), + ); + return; + } + + final box = context.findRenderObject() as RenderBox?; + final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero; + final size = box?.size ?? Size.zero; + + _emojiOverlay = OverlayEntry(builder: (ctx) { + final screenSize = MediaQuery.sizeOf(ctx); + const pickerW = 340.0; + const pickerH = 380.0; + final left = (offset.dx + size.width - pickerW).clamp(8.0, screenSize.width - pickerW - 8); + final top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8); + return Material( + type: MaterialType.transparency, + child: Stack(children: [ + GestureDetector( + onTap: () { _emojiOverlay?.remove(); _emojiOverlay = null; }, + behavior: HitTestBehavior.translucent, + child: const SizedBox.expand(), + ), + Positioned( + left: left, + top: top, + child: EmojiPicker( + onEmojiSelected: (emoji) => onEmojiSelected(emoji, null), + onClose: () { _emojiOverlay?.remove(); _emojiOverlay = null; }, + ), + ), + ]), + ); + }); + Overlay.of(context).insert(_emojiOverlay!); + } + + Future _pickAttachment() async { + final result = await FilePicker.platform.pickFiles(allowMultiple: true); + if (result == null || result.files.isEmpty) return; + if (!mounted) return; + final files = result.files + .where((f) => f.path != null) + .map((f) => File(f.path!)) + .toList(); + if (files.isEmpty) return; + await AttachmentDialog.show(context, files, widget.room, replyTo: widget.replyTo); + widget.onClearReply?.call(); + } + + Future _send() async { + final text = _ctrl.text.trim(); + if (text.isEmpty) return; + _ctrl.clear(); + setState(() => _canSend = false); + // Stop typing indicator immediately before sending. + _typingTimer?.cancel(); + if (_isTyping) _sendTyping(false); + await widget.room.sendTextEvent(text, inReplyTo: widget.replyTo); + widget.onClearReply?.call(); + _focus.requestFocus(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final size = MediaQuery.sizeOf(context); + final isSmall = size.width < 400; + + final roomName = widget.room.getLocalizedDisplayname(); + final placeholder = widget.room.isDirectChat + ? 'Message…' + : 'Message #$roomName'; + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.replyTo != null) + _ReplyBar( + event: widget.replyTo!, + onClear: widget.onClearReply ?? () {}, + pt: pt, + ), + Padding( + // Bottom inset matches the profile block in the rooms list (8px) so + // the composer sits flush with it. + padding: EdgeInsets.fromLTRB(isSmall ? 8 : 16, 0, isSmall ? 8 : 16, 8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + // Tuned so the single-line composer is flush with the profile + // block in the rooms list (~89px on the user's display). Grows for + // multiline. + constraints: const BoxConstraints(minHeight: 53), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(pt.rBase + 4), + border: Border.all( + color: _focus.hasFocus ? pt.accent : pt.border, + ), + boxShadow: _focus.hasFocus + ? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)] + : null, + ), + child: Focus( + onFocusChange: (_) => setState(() {}), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Attach button + _ComposerIconBtn( + icon: Icons.add_rounded, + tooltip: 'Datei anhängen', + pt: pt, + size: size.width < 600 ? 24 : 30, + onTap: _pickAttachment, + ), + // Text input + Expanded( + child: TextField( + controller: _ctrl, + focusNode: _focus, + minLines: 1, + maxLines: 8, + style: TextStyle(color: pt.fg, fontSize: 14), + cursorColor: pt.accent, + decoration: InputDecoration( + hintText: placeholder, + hintStyle: TextStyle(color: pt.fgDim, fontSize: 14), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: size.width < 600 ? 4 : 8, + vertical: 10, + ), + isDense: true, + ), + inputFormatters: [ + if (!(Platform.isAndroid || Platform.isIOS)) + _SendOnEnterFormatter(onSend: _send), + ], + // Meldet der Android-Tastatur, dass dieses Feld Bilder + // annimmt — dadurch bietet z.B. Gboard frisch kopierte + // Bilder direkt in der Vorschlagsleiste an. + contentInsertionConfiguration: Platform.isAndroid + ? ContentInsertionConfiguration( + allowedMimeTypes: const [ + 'image/gif', + 'image/png', + 'image/jpeg', + 'image/webp', + ], + onContentInserted: _handleInsertedContent, + ) + : null, + ), + ), + // Inline tools + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ComposerIconBtn( + icon: Icons.gif_box_outlined, + tooltip: 'GIF', + pt: pt, + size: size.width < 600 ? 22 : 26, + onTap: _toggleGifPicker, + ), + _ComposerIconBtn( + icon: Icons.tag_faces_rounded, + tooltip: 'Emoji', + pt: pt, + size: size.width < 600 ? 22 : 26, + onTap: _toggleEmojiPicker, + ), + if (size.width > 600) + _ComposerIconBtn( + icon: Icons.alternate_email_rounded, + tooltip: 'Mention', + pt: pt, + size: 26, + ), + ], + ), + // Send button — only on mobile + if (Platform.isAndroid || Platform.isIOS) + Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 8, 4), + child: _SendButton( + canSend: _canSend, + pt: pt, + onTap: _send, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class _ComposerIconBtn extends StatefulWidget { + final IconData icon; + final String tooltip; + final PyramidTheme pt; + final double size; + final VoidCallback? onTap; + + const _ComposerIconBtn({ + required this.icon, + required this.tooltip, + required this.pt, + required this.size, + this.onTap, + }); + + @override + State<_ComposerIconBtn> createState() => _ComposerIconBtnState(); +} + +class _ComposerIconBtnState extends State<_ComposerIconBtn> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final platform = Theme.of(context).platform; + final isMobile = + platform == TargetPlatform.android || platform == TargetPlatform.iOS; + final touchSize = isMobile ? 44.0 : widget.size; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: Tooltip( + message: widget.tooltip, + waitDuration: const Duration(milliseconds: 500), + child: SizedBox( + width: touchSize, + height: touchSize, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: _hovered ? widget.pt.bgHover : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Icon( + widget.icon, + size: widget.size * 0.55, + color: _hovered ? widget.pt.fg : widget.pt.fgMuted, + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _SendButton extends StatefulWidget { + final bool canSend; + final PyramidTheme pt; + final VoidCallback onTap; + + const _SendButton({required this.canSend, required this.pt, required this.onTap}); + + @override + State<_SendButton> createState() => _SendButtonState(); +} + +class _SendButtonState extends State<_SendButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final active = widget.canSend; + + return MouseRegion( + cursor: active ? SystemMouseCursors.click : MouseCursor.defer, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: active ? widget.onTap : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutBack, + width: 36, + height: 36, + decoration: BoxDecoration( + color: active ? pt.accent : pt.bg3, + borderRadius: BorderRadius.circular(pt.rSm), + boxShadow: active && _hovered + ? [BoxShadow(color: pt.accentGlow, blurRadius: 12, offset: const Offset(0, 4))] + : null, + ), + transform: active + ? (_hovered + ? (Matrix4.diagonal3Values(1.1, 1.1, 1.0)..rotateZ(-0.26)) + : Matrix4.diagonal3Values(1.05, 1.05, 1.0)) + : Matrix4.identity(), + transformAlignment: Alignment.center, + child: Center( + child: Icon( + Icons.send_rounded, + size: 16, + color: active ? pt.accentFg : pt.fgDim, + ), + ), + ), + ), + ); + } +} + +class _ReplyBar extends StatelessWidget { + final Event event; + final VoidCallback onClear; + final PyramidTheme pt; + + const _ReplyBar({required this.event, required this.onClear, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 4), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: pt.border), + left: BorderSide(color: pt.accent, width: 3), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.senderFromMemoryOrFallback.calcDisplayname(), + style: TextStyle( + color: pt.accent, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + Text( + event.body, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: pt.fgMuted, fontSize: 12), + ), + ], + ), + ), + GestureDetector( + onTap: onClear, + child: Icon(Icons.close_rounded, size: 16, color: pt.fgDim), + ), + ], + ), + ); + } +} + +class _SendOnEnterFormatter extends TextInputFormatter { + final VoidCallback onSend; + _SendOnEnterFormatter({required this.onSend}); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (newValue.text.endsWith('\n') && !oldValue.text.endsWith('\n')) { + // Check if shift is pressed (can't detect here, just send on enter) + // For multiline we'd need keyboard shortcuts + WidgetsBinding.instance.addPostFrameCallback((_) => onSend()); + return oldValue; + } + return newValue; + } +} diff --git a/lib/features/chat/chat_input.dart b/lib/features/chat/chat_input.dart deleted file mode 100644 index e88b3af..0000000 --- a/lib/features/chat/chat_input.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:matrix/matrix.dart'; - -class ChatInput extends StatefulWidget { - final Room room; - final Event? replyTo; - final VoidCallback? onClearReply; - - const ChatInput({ - super.key, - required this.room, - this.replyTo, - this.onClearReply, - }); - - @override - State createState() => _ChatInputState(); -} - -class _ChatInputState extends State { - final _ctrl = TextEditingController(); - bool _canSend = false; - - @override - void initState() { - super.initState(); - _ctrl.addListener(() { - final hasText = _ctrl.text.trim().isNotEmpty; - if (hasText != _canSend) setState(() => _canSend = hasText); - }); - } - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - Future _send() async { - final text = _ctrl.text.trim(); - if (text.isEmpty) return; - _ctrl.clear(); - setState(() => _canSend = false); - await widget.room.sendTextEvent(text, inReplyTo: widget.replyTo); - widget.onClearReply?.call(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isDesktop = !kIsWeb && - (defaultTargetPlatform == TargetPlatform.windows || - defaultTargetPlatform == TargetPlatform.linux || - defaultTargetPlatform == TargetPlatform.macOS); - - // Desktop: taller bar, 4px square corners, bigger button - // Mobile: compact bar, pill shape, smaller button - final fieldRadius = isDesktop ? 4.0 : 20.0; - final btnSize = isDesktop ? 56.0 : 44.0; - final btnRadius = isDesktop ? 6.0 : 12.0; - final iconSize = isDesktop ? 24.0 : 20.0; - final fontSize = isDesktop ? 15.0 : 14.0; - final vPad = isDesktop ? 18.0 : 10.0; - final hPad = isDesktop ? 16.0 : 14.0; - final outerPad = isDesktop - ? const EdgeInsets.fromLTRB(12, 10, 12, 12) - : const EdgeInsets.fromLTRB(8, 4, 8, 8); - - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.replyTo != null) - _ReplyBar( - event: widget.replyTo!, - onClear: widget.onClearReply ?? () {}, - ), - Padding( - padding: outerPad, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextField( - controller: _ctrl, - minLines: 1, - maxLines: 6, - textCapitalization: TextCapitalization.sentences, - style: TextStyle(fontSize: fontSize), - decoration: InputDecoration( - hintText: 'Nachricht...', - hintStyle: TextStyle(fontSize: fontSize), - contentPadding: EdgeInsets.symmetric( - horizontal: hPad, - vertical: vPad, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(fieldRadius), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(fieldRadius), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(fieldRadius), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: theme.colorScheme.surfaceContainerHigh, - ), - onSubmitted: (_) => _send(), - keyboardType: TextInputType.multiline, - inputFormatters: [ - _SendOnEnterFormatter(onSend: _send), - ], - ), - ), - SizedBox(width: isDesktop ? 10 : 8), - GestureDetector( - onTap: _canSend ? _send : null, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: btnSize, - height: btnSize, - decoration: BoxDecoration( - color: _canSend - ? theme.colorScheme.primary - : theme.colorScheme.primary.withAlpha(70), - borderRadius: BorderRadius.circular(btnRadius), - ), - child: Center( - child: Icon( - Icons.send_rounded, - size: iconSize, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _ReplyBar extends StatelessWidget { - final Event event; - final VoidCallback onClear; - const _ReplyBar({required this.event, required this.onClear}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.fromLTRB(16, 8, 8, 4), - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: theme.dividerColor), - left: BorderSide(color: theme.colorScheme.primary, width: 3), - ), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - event.senderFromMemoryOrFallback.calcDisplayname(), - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - Text( - event.body, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.close, size: 18), - onPressed: onClear, - ), - ], - ), - ); - } -} - -class _SendOnEnterFormatter extends TextInputFormatter { - final VoidCallback onSend; - _SendOnEnterFormatter({required this.onSend}); - - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - return newValue; - } -} diff --git a/lib/features/chat/chat_page.dart b/lib/features/chat/chat_page.dart deleted file mode 100644 index d69e972..0000000 --- a/lib/features/chat/chat_page.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; -import 'package:pyramid/features/chat/chat_input.dart'; -import 'package:pyramid/features/chat/chat_provider.dart'; -import 'package:pyramid/features/chat/message_bubble.dart'; - -class ChatPage extends ConsumerStatefulWidget { - final String roomId; - const ChatPage({super.key, required this.roomId}); - - @override - ConsumerState createState() => _ChatPageState(); -} - -class _ChatPageState extends ConsumerState { - final _scrollCtrl = ScrollController(); - Event? _replyTo; - - String get _roomId => Uri.decodeComponent(widget.roomId); - - @override - void initState() { - super.initState(); - _scrollCtrl.addListener(_onScroll); - } - - @override - void dispose() { - _scrollCtrl.dispose(); - super.dispose(); - } - - void _onScroll() { - if (_scrollCtrl.position.pixels >= - _scrollCtrl.position.maxScrollExtent - 300) { - final timeline = ref.read(timelineProvider(_roomId)).valueOrNull; - timeline?.requestHistory(); - } - } - - @override - Widget build(BuildContext context) { - final room = ref.watch(roomProvider(_roomId)); - final timelineAsync = ref.watch(timelineProvider(_roomId)); - - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/rooms'), - ), - title: Text(room?.getLocalizedDisplayname() ?? _roomId), - actions: [ - IconButton( - icon: const Icon(Icons.info_outline), - onPressed: () {}, - ), - ], - ), - body: Column( - children: [ - Expanded( - child: timelineAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Fehler: $e')), - data: (timeline) => _MessageList( - timeline: timeline, - scrollCtrl: _scrollCtrl, - currentUserId: room?.client.userID ?? '', - onReply: (event) => setState(() => _replyTo = event), - ), - ), - ), - if (room != null) - ChatInput( - room: room, - replyTo: _replyTo, - onClearReply: () => setState(() => _replyTo = null), - ), - ], - ), - ); - } -} - -class _MessageList extends StatelessWidget { - final Timeline timeline; - final ScrollController scrollCtrl; - final String currentUserId; - final ValueChanged onReply; - - const _MessageList({ - required this.timeline, - required this.scrollCtrl, - required this.currentUserId, - required this.onReply, - }); - - @override - Widget build(BuildContext context) { - final events = timeline.events - .where((e) => - e.type == EventTypes.Message && - e.status != EventStatus.error) - .toList(); - - if (events.isEmpty) { - return const Center(child: Text('Noch keine Nachrichten')); - } - - return ListView.builder( - controller: scrollCtrl, - reverse: true, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: events.length, - itemBuilder: (context, i) { - final event = events[i]; - final isOwn = event.senderId == currentUserId; - - final showDate = i == events.length - 1 || - !_sameDay(event.originServerTs, events[i + 1].originServerTs); - - final replyEventId = - event.content.tryGetMap('m.relates_to') - ?['m.in_reply_to']?['event_id'] as String?; - final replyEvent = replyEventId != null - ? timeline.events.firstWhere( - (e) => e.eventId == replyEventId, - orElse: () => event, - ) - : null; - - return Column( - children: [ - if (showDate) - DateSeparator(date: event.originServerTs), - GestureDetector( - onLongPress: () => onReply(event), - child: MessageBubble( - event: event, - isOwn: isOwn, - replyEvent: - replyEvent?.eventId != event.eventId ? replyEvent : null, - ), - ), - ], - ); - }, - ); - } - - bool _sameDay(DateTime a, DateTime b) => - a.year == b.year && a.month == b.month && a.day == b.day; -} diff --git a/lib/features/chat/chat_provider.dart b/lib/features/chat/chat_provider.dart index 8f10bbe..7d53d92 100644 --- a/lib/features/chat/chat_provider.dart +++ b/lib/features/chat/chat_provider.dart @@ -1,20 +1,145 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/e2ee_diagnostics.dart'; import 'package:pyramid/core/matrix_client.dart'; +// AsyncNotifier so onUpdate can refresh state in-place without recreating the +// timeline (which would reset the loaded event window back to the default 30). +class TimelineNotifier extends FamilyAsyncNotifier { + Timeline? _timeline; + StreamSubscription? _roomUpdateSub; + Timer? _redecryptTimer; + + @override + Future build(String arg) async { + final roomId = arg; + final client = await ref.watch(matrixClientProvider.future); + final room = client.getRoomById(roomId); + if (room == null) throw Exception('Raum nicht gefunden: $roomId'); + + _timeline?.cancelSubscriptions(); + _roomUpdateSub?.cancel(); + _redecryptTimer?.cancel(); + + final diag = ref.read(e2eeDiagnosticsProvider.notifier); + + final timeline = await room.getTimeline( + onUpdate: _onUpdate, + ); + _timeline = timeline; + + if (timeline.events.length < 20) { + await timeline.requestHistory(historyCount: 60); + } + + timeline.requestKeys(onlineKeyBackupOnly: false); + unawaited(_decryptLegacyEvents(client, timeline, roomId, diag)); + + // Re-decrypt whenever the room updates (e.g. after new keys arrive from + // key requests). Debounced to avoid redundant passes on rapid updates. + _roomUpdateSub = room.onUpdate.stream.listen((_) { + _redecryptTimer?.cancel(); + _redecryptTimer = Timer(const Duration(milliseconds: 800), () { + final t = _timeline; + if (t != null) { + unawaited(_decryptLegacyEvents(client, t, roomId, diag)); + } + }); + }); + + // Ensure device keys are downloaded for all room members so outbound + // Megolm session key sharing works on the first send. + if (room.encrypted && client.encryptionEnabled) { + unawaited(_preloadDeviceKeys(client, room)); + } + + ref.onDispose(() { + _roomUpdateSub?.cancel(); + _redecryptTimer?.cancel(); + _timeline?.cancelSubscriptions(); + _timeline = null; + }); + return timeline; + } + + void _onUpdate() { + final t = _timeline; + if (t != null) state = AsyncData(t); + } +} + final timelineProvider = - FutureProvider.family((ref, roomId) async { - final client = await ref.watch(matrixClientProvider.future); - final room = client.getRoomById(roomId); - if (room == null) throw Exception('Raum nicht gefunden: $roomId'); + AsyncNotifierProvider.family( + TimelineNotifier.new, +); - final timeline = await room.getTimeline( - onUpdate: () => ref.invalidateSelf(), - ); +Future _decryptLegacyEvents( + Client client, + Timeline timeline, + String roomId, + E2eeDiagnosticsNotifier diag, +) async { + final enc = client.encryption; + if (enc == null) return; - ref.onDispose(timeline.cancelSubscriptions); - return timeline; -}); + try { + await enc.keyManager.loadAllKeysFromRoom(roomId); + } catch (e) { + Logs().e('[ChatProvider] loadAllKeysFromRoom failed', e); + } + + if (!enc.enabled) return; + + var changed = false; + for (var i = 0; i < timeline.events.length; i++) { + final event = timeline.events[i]; + if (event.type != EventTypes.Encrypted) continue; + try { + final decrypted = await enc.decryptRoomEvent( + event, + store: true, + updateType: EventUpdateType.history, + ); + if (decrypted.type != EventTypes.Encrypted) { + timeline.events[i] = decrypted; + changed = true; + } else { + // Still encrypted — log for diagnostics + final sessionId = event.content.tryGet('session_id') ?? ''; + diag.add(E2eeDiagEntry( + timestamp: DateTime.now(), + roomId: roomId, + eventId: event.eventId, + sessionId: sessionId, + error: decrypted.content.tryGet('body') ?? 'unknown', + )); + } + } catch (e) { + final sessionId = event.content.tryGet('session_id') ?? ''; + diag.add(E2eeDiagEntry( + timestamp: DateTime.now(), + roomId: roomId, + eventId: event.eventId, + sessionId: sessionId, + error: e.toString(), + )); + } + } + // Notify UI of in-place changes only when something actually decrypted + if (changed) timeline.onUpdate?.call(); +} + +Future _preloadDeviceKeys(Client client, Room room) async { + try { + final members = await room.requestParticipants([Membership.join, Membership.invite]); + final userIds = members.map((m) => m.id).toSet(); + await client.updateUserDeviceKeys(additionalUsers: userIds); + } catch (e) { + Logs().w('[ChatProvider] Device key preload failed: $e'); + } +} final roomProvider = Provider.family((ref, roomId) { final client = ref.watch(matrixClientProvider).valueOrNull; diff --git a/lib/features/chat/chat_view.dart b/lib/features/chat/chat_view.dart new file mode 100644 index 0000000..e96d4ff --- /dev/null +++ b/lib/features/chat/chat_view.dart @@ -0,0 +1,2115 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart' show Clipboard, ClipboardData; + +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/settings_prefs.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/features/chat/attachment_dialog.dart'; +import 'package:pyramid/features/chat/chat_composer.dart'; +import 'package:pyramid/features/chat/chat_provider.dart'; +import 'package:pyramid/features/chat/message_group.dart'; +import 'package:pyramid/features/chat/pinned_messages_panel.dart'; +import 'package:pyramid/widgets/hover_region.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; + +import 'package:pyramid/core/notification_service.dart'; +import 'package:pyramid/core/voip_manager.dart'; + +class ChatView extends ConsumerStatefulWidget { + final String roomId; + const ChatView({super.key, required this.roomId}); + + @override + ConsumerState createState() => _ChatViewState(); +} + +class _ChatViewState extends ConsumerState { + final _scrollCtrl = ScrollController(); + Event? _replyTo; + bool _loadingHistory = false; + bool _historyExhausted = false; + bool _draggingOver = false; + bool _pinnedOpen = false; + bool _profileExpanded = false; + bool _showScrollDown = false; + final Set _selectedIds = {}; + + // Unread state captured at session open (before marking as read) + String? _sessionFullyReadId; + bool _sessionHasUnread = false; + bool _sessionCaptured = false; + bool _didScrollToUnread = false; + bool _loadingFuture = false; + // Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom. + bool _unreadFading = false; + Timer? _unreadFadeTimer; + + // Jump-to-message (from search): scroll to + briefly highlight a target event. + final _jumpKey = GlobalKey(); + String? _jumpEventId; + String? _highlightEventId; + bool _highlightOn = false; + bool _jumpInProgress = false; + Timer? _highlightTimer; + + void _startJump(String eventId) { + _highlightTimer?.cancel(); + setState(() { + _jumpEventId = eventId; + _highlightEventId = eventId; + _highlightOn = true; + _jumpInProgress = true; + }); + // Consume the request so it doesn't re-fire. + ref.read(pendingJumpEventProvider.notifier).state = null; + WidgetsBinding.instance.addPostFrameCallback((_) => _attemptJump(0)); + } + + void _attemptJump(int attempt) { + if (!mounted || _jumpEventId == null) return; + final ctx = _jumpKey.currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + alignment: 0.4, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + _jumpInProgress = false; + // Hold the highlight briefly, then fade it out smoothly. + _highlightTimer?.cancel(); + _highlightTimer = Timer(const Duration(milliseconds: 1300), () { + if (!mounted) return; + setState(() => _highlightOn = false); // triggers the AnimatedOpacity fade + _highlightTimer = Timer(const Duration(milliseconds: 950), () { + if (mounted) setState(() { _highlightEventId = null; _jumpEventId = null; }); + }); + }); + return; + } + if (attempt >= 40) { + // Couldn't reach the event — give up gracefully (room is at least open). + setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; }); + return; + } + // Not built yet — load older history toward the target every few tries. + final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull; + if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) { + timeline.requestHistory(historyCount: 100).catchError((_) {}); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1)); + }); + } + + // GlobalKey for the "Neue Nachrichten" divider — used by ensureVisible to + // scroll precisely to the divider on chat open. + final _unreadDividerKey = GlobalKey(); + + bool get _inSelectionMode => _selectedIds.isNotEmpty; + + void _toggleSelect(String eventId) { + setState(() { + if (_selectedIds.contains(eventId)) { + _selectedIds.remove(eventId); + } else { + _selectedIds.add(eventId); + } + }); + } + + void _clearSelection() => setState(() => _selectedIds.clear()); + + @override + void initState() { + super.initState(); + _scrollCtrl.addListener(_onScroll); + } + + @override + void didUpdateWidget(ChatView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.roomId != widget.roomId) { + _profileExpanded = false; + _sessionFullyReadId = null; + _sessionHasUnread = false; + _sessionCaptured = false; + _didScrollToUnread = false; + _loadingFuture = false; + _historyExhausted = false; + _unreadFading = false; + _unreadFadeTimer?.cancel(); + _jumpEventId = null; + _highlightEventId = null; + _highlightOn = false; + _jumpInProgress = false; + _highlightTimer?.cancel(); + // The GlobalKey stays alive — it'll simply have no context until the + // new room's divider (if any) is built. + } + } + + @override + void dispose() { + _unreadFadeTimer?.cancel(); + _highlightTimer?.cancel(); + _scrollCtrl.dispose(); + super.dispose(); + } + + void _captureAndMarkRead(Timeline timeline) { + if (!_sessionCaptured) { + final room = timeline.room; + final fullyRead = room.fullyRead; // "" if never set + final newFullyReadId = fullyRead.isNotEmpty ? fullyRead : null; + + // Show the divider only if there's a genuinely unread VISIBLE message + // FROM SOMEONE ELSE after the read marker. Own messages and hidden events + // (reactions, receipts, edits) don't count — otherwise the divider ends + // up below all messages (e.g. right after one's own latest message). + final me = room.client.userID; + final events = timeline.events; // newest-first + final frIndex = + fullyRead.isEmpty ? -1 : events.indexWhere((e) => e.eventId == fullyRead); + // Indices [0, upTo) are newer than the read marker. + final upTo = frIndex >= 0 ? frIndex : events.length; + var newHasUnread = false; + for (var i = 0; i < upTo; i++) { + final e = events[i]; + if (_MessageList._isVisible(e) && e.senderId != me) { + newHasUnread = true; + break; + } + } + + _sessionCaptured = true; + + // setState so _MessageList rebuilds with the captured unread marker. + if (mounted) { + setState(() { + _sessionFullyReadId = newFullyReadId; + _sessionHasUnread = newHasUnread; + }); + if (newHasUnread) { + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline)); + } + } + } + _markRead(timeline); + } + + void _scrollToUnread(Timeline timeline) { + if (_didScrollToUnread) return; + _attemptScrollToUnread(0); + } + + // The "Neue Nachrichten" divider may not be laid out yet on first open (lazy + // reverse ListView only builds visible + cacheExtent items). Retry until its + // context exists, nudging the scroll upward periodically so the list builds + // toward the divider when there are many unread messages. + void _attemptScrollToUnread(int attempt) { + if (_didScrollToUnread || !mounted) return; + final ctx = _unreadDividerKey.currentContext; + if (ctx != null) { + _didScrollToUnread = true; + // alignment 0.85: in a reverse list this lands the divider near the top + // of the viewport with a little read context above it and the unread + // messages filling the screen below. + Scrollable.ensureVisible( + ctx, + alignment: 0.85, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + return; + } + if (attempt >= 24) { + _didScrollToUnread = true; // give up — stay at the newest messages + return; + } + if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) { + final pos = _scrollCtrl.position; + final target = (pos.pixels + 1500).clamp(0.0, pos.maxScrollExtent); + if (target > pos.pixels) _scrollCtrl.jumpTo(target); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 70), + () => _attemptScrollToUnread(attempt + 1)); + }); + } + + void _markRead([Timeline? tl]) { + // Cancel any pending notification for this room (user is now reading it). + cancelNotificationForRoom(widget.roomId); + + final sendReceipts = ref.read(privacyReadReceiptsProvider); + if (!sendReceipts) return; + final room = ref.read(roomProvider(widget.roomId)); + if (room == null) return; + final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull; + final latest = timeline?.events.firstOrNull; + if (latest != null && latest.status.isSynced) { + room.setReadMarker(latest.eventId, mRead: latest.eventId) + .catchError((_) {}); + } else { + room.markUnread(false).catchError((_) {}); + } + } + + Future _onScroll() async { + if (!_scrollCtrl.hasClients) return; + final pixels = _scrollCtrl.position.pixels; + final maxExtent = _scrollCtrl.position.maxScrollExtent; + + if (pixels < 60) { + _markRead(null); + // Reached the newest messages → the "Neue Nachrichten" divider is no + // longer useful. Fade it out, then remove it. + if (_sessionHasUnread && !_unreadFading) { + setState(() => _unreadFading = true); + _unreadFadeTimer?.cancel(); + _unreadFadeTimer = Timer(const Duration(milliseconds: 900), () { + if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; }); + }); + } + } + final shouldShow = pixels > 400; + if (shouldShow != _showScrollDown) { + setState(() => _showScrollDown = shouldShow); + } + + final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull; + if (timeline == null) return; + + // Backward pagination — older events near the top. We keep requesting until + // a request genuinely returns nothing new (real start of room), tracked via + // _historyExhausted, so we neither stop early nor hammer the server. + if (!_loadingHistory && + !_historyExhausted && + pixels >= maxExtent - 600) { + _loadingHistory = true; + final oldestBefore = timeline.events.isNotEmpty ? timeline.events.last.eventId : null; + final lenBefore = timeline.events.length; + try { + await timeline.requestHistory(historyCount: 60); + } catch (_) {} + final oldestAfter = timeline.events.isNotEmpty ? timeline.events.last.eventId : null; + // Nothing older arrived → we've reached the beginning of the room. + if (timeline.events.length == lenBefore && oldestBefore == oldestAfter) { + _historyExhausted = true; + } + await Future.delayed(const Duration(milliseconds: 200)); + _loadingHistory = false; + } + + // Forward pagination — only relevant after jumping to a historical anchor + // (e.g. from search). At the live edge canRequestFuture is false, so this + // won't fire during normal reading and can't pull the view back down. + if (!_loadingFuture && + timeline.canRequestFuture && + pixels < 200) { + _loadingFuture = true; + try { + await timeline.requestFuture(historyCount: 60); + } catch (_) {} + await Future.delayed(const Duration(milliseconds: 250)); + _loadingFuture = false; + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final room = ref.watch(roomProvider(widget.roomId)); + final timelineAsync = ref.watch(timelineProvider(widget.roomId)); + final membersOpen = ref.watch(membersPanelOpenProvider); + + // A search result requested a jump to a specific message. Using watch (not + // listen) so it also fires when this ChatView is freshly mounted for a + // DIFFERENT room — listen would miss a value set before registration. + final pendingJump = ref.watch(pendingJumpEventProvider); + if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && + !_jumpInProgress && + ref.read(pendingJumpEventProvider) == pendingJump) { + _startJump(pendingJump); + } + }); + } + + final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; + final body = Stack( + fit: StackFit.expand, + children: [ + Row( + children: [ + Expanded( + child: Container( + color: pt.bg1, + child: Column( + children: [ + if (_inSelectionMode) + _SelectionHeader( + pt: pt, + count: _selectedIds.length, + selectedIds: Set.from(_selectedIds), + room: room, + timeline: ref.read(timelineProvider(widget.roomId)).valueOrNull, + currentUserId: room?.client.userID ?? '', + onClear: _clearSelection, + onReply: () { + final tl = ref.read(timelineProvider(widget.roomId)).valueOrNull; + if (tl == null) return; + try { + final event = tl.events.firstWhere((e) => e.eventId == _selectedIds.first); + setState(() { _replyTo = event; _selectedIds.clear(); }); + } catch (_) {} + }, + ) + else if (room != null && room.isDirectChat) + // DM: aufklappbarer Banner-Header — Avatar und Name + // skalieren mit, Banner und Bio blenden ein. + // ValueKey: pro Raum ein frischer State, damit beim + // schnellen Kanalwechsel keinerlei Daten (Avatar, + // Banner, laufende Futures) vom Vorgänger übrigbleiben. + _DmExpandingHeader( + key: ValueKey('dmheader:${room.id}'), + room: room, + roomId: widget.roomId, + pt: pt, + expanded: _profileExpanded, + onToggle: () => setState( + () => _profileExpanded = !_profileExpanded), + membersOpen: membersOpen, + pinnedOpen: _pinnedOpen, + onToggleMembers: () => ref + .read(membersPanelOpenProvider.notifier) + .update((s) => !s), + onOpenSearch: () => + ref.read(activeModalProvider.notifier).state = + ModalKind.search, + onTogglePinned: () => + setState(() => _pinnedOpen = !_pinnedOpen), + ) + else + _ChatHeader( + room: room, + roomId: widget.roomId, + pt: pt, + membersOpen: membersOpen, + pinnedOpen: _pinnedOpen, + onToggleMembers: () => ref + .read(membersPanelOpenProvider.notifier) + .update((s) => !s), + onOpenSearch: () => + ref.read(activeModalProvider.notifier).state = + ModalKind.search, + onTogglePinned: () => + setState(() => _pinnedOpen = !_pinnedOpen), + ), + const _ConnectionBanner(), + Expanded( + child: Stack( + children: [ + timelineAsync.when( + loading: () => Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: pt.accent, + ), + ), + error: (e, _) => Center( + child: Text( + 'Could not load messages', + style: TextStyle(color: pt.fgDim, fontSize: 14), + ), + ), + data: (timeline) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _captureAndMarkRead(timeline)); + return _MessageList( + timeline: timeline, + scrollCtrl: _scrollCtrl, + currentUserId: room?.client.userID ?? '', + pt: pt, + onReply: (event) => setState(() => _replyTo = event), + selectedIds: _selectedIds, + onToggleSelect: _toggleSelect, + sessionFullyReadId: _sessionFullyReadId, + sessionHasUnread: _sessionHasUnread, + unreadFading: _unreadFading, + unreadDividerKey: _sessionHasUnread ? _unreadDividerKey : null, + jumpEventId: _jumpEventId, + jumpKey: _jumpKey, + highlightEventId: _highlightEventId, + highlightOn: _highlightOn, + ); + }, + ), + if (_showScrollDown) + Positioned( + right: 16, + bottom: 12, + child: AnimatedOpacity( + opacity: _showScrollDown ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: GestureDetector( + onTap: () => _scrollCtrl.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: pt.bg0, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: pt.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(80), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: pt.fgMuted), + ), + ), + ), + ), + ], + ), + ), + if (room != null) _TypingIndicator(room: room, pt: pt), + if (room != null) + ChatComposer( + room: room, + replyTo: _replyTo, + onClearReply: () => setState(() => _replyTo = null), + ), + ], + ), + ), + ), + if (_pinnedOpen) + PinnedMessagesPanel( + roomId: widget.roomId, + onClose: () => setState(() => _pinnedOpen = false), + ), + ], + ), + // Drag-and-drop overlay — bleibt gemountet und blendet animiert + // ein/aus, damit der Übergang flüssig statt abrupt ist. + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _draggingOver ? 1 : 0, + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + child: _DragOverlay(pt: pt, active: _draggingOver), + ), + ), + ), + ], + ); + + if (!isDesktop) return body; + + return DropTarget( + onDragEntered: (_) => setState(() => _draggingOver = true), + onDragExited: (_) => setState(() => _draggingOver = false), + onDragDone: (details) async { + setState(() => _draggingOver = false); + if (room == null) return; + final files = details.files.map((xf) => File(xf.path)).toList(); + if (files.isEmpty) return; + if (!mounted) return; + await AttachmentDialog.show(context, files, room, replyTo: _replyTo); + if (mounted) setState(() => _replyTo = null); + }, + child: body, + ); + } +} + +class _ChatHeader extends ConsumerWidget { + final Room? room; + final String roomId; + final PyramidTheme pt; + final bool membersOpen; + final bool pinnedOpen; + final VoidCallback onToggleMembers; + final VoidCallback onOpenSearch; + final VoidCallback onTogglePinned; + + const _ChatHeader({ + required this.room, + required this.roomId, + required this.pt, + required this.membersOpen, + required this.pinnedOpen, + required this.onToggleMembers, + required this.onOpenSearch, + required this.onTogglePinned, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final name = room?.getLocalizedDisplayname() ?? roomId; + final isDm = room?.isDirectChat ?? false; + final isEncrypted = room?.encrypted ?? false; + final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name'); + final client = ref.watch(matrixClientProvider).valueOrNull; + final size = MediaQuery.sizeOf(context); + final isMobile = size.width < 900 || size.height < 500; + + return Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: pt.bg1, + border: Border(bottom: BorderSide(color: pt.border)), + ), + child: Row( + children: [ + // Back button on mobile + if (isMobile) ...[ + GestureDetector( + onTap: () => ref.read(activeRoomIdProvider.notifier).state = null, + child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), + ), + const SizedBox(width: 12), + ], + // Room icon / avatar + if (isDm && client != null) ...[ + _DmHeaderAvatar(room: room, name: name, client: client), + ] else if (!isDm) ...[ + Icon( + isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded, + size: 16, + color: pt.fgDim, + ), + ], + const SizedBox(width: 8), + // Name + presence for DMs + if (isDm) + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + color: pt.fg, + fontSize: 15, + fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + if (room != null && client != null) + _PresenceLine( + userId: room!.directChatMatrixID, + client: client, + pt: pt, + ), + ], + ), + ) + else + Text( + name, + style: TextStyle( + color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), + ), + const SizedBox(width: 12), + if (!isDm && topic.isNotEmpty) ...[ + Container(width: 1, height: 18, color: pt.border), + const SizedBox(width: 12), + Expanded( + child: Text( + topic, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + ), + ] else if (!isDm) + const Spacer(), + // Right actions + Row( + children: [ + if (isDm) ...[ + PyrIconBtn( + size: 28, + icon: const Icon(Icons.phone_rounded), + tooltip: 'Voice call', + onPressed: () => _startCall(ref, room, cam: false), + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.videocam_rounded), + tooltip: 'Video call', + onPressed: () => _startCall(ref, room, cam: true), + ), + if (size.width > 700) + Container( + width: 1, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: pt.border, + ), + ], + if (size.width > 800) ...[ + PyrIconBtn( + size: 28, + icon: const Icon(Icons.inbox_rounded), + tooltip: 'Threads', + onPressed: () {}, + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.push_pin_rounded), + tooltip: 'Angepinnte Nachrichten', + active: pinnedOpen, + onPressed: onTogglePinned, + ), + ], + PyrIconBtn( + size: 28, + icon: const Icon(Icons.search_rounded), + tooltip: 'Search', + onPressed: onOpenSearch, + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.group_rounded), + tooltip: 'Members', + active: membersOpen, + onPressed: onToggleMembers, + ), + ], + ), + ], + ), + ); + } + + void _startCall(WidgetRef ref, Room? room, {required bool cam}) async { + if (room == null) return; + + if (room.isDirectChat) { + // Use native Matrix VoIP for 1:1 DMs + ref.read(viewModeProvider.notifier).state = ViewMode.voice; + await PyramidVoipManager.instance.startCall(room.id, video: cam); + return; + } + + // Use LiveKit for group voice rooms + ref.read(viewModeProvider.notifier).state = ViewMode.voice; + await ref.read(callStateProvider).startCall( + roomName: roomId, + roomDisplayName: room.getLocalizedDisplayname(), + identity: 'user_${DateTime.now().millisecondsSinceEpoch}', + audioOnly: !cam, + ); + } +} + +/// DM-Header, der sich per Klick aufklappt: Das Banner blendet als Hintergrund +/// ein, Avatar und Name skalieren mit, Handle + Bio erscheinen darunter +/// (Design nach Nutzer-Mockup, Twitter/Discord-artig). +class _DmExpandingHeader extends ConsumerStatefulWidget { + final Room room; + final String roomId; + final PyramidTheme pt; + final bool expanded; + final VoidCallback onToggle; + final bool membersOpen; + final bool pinnedOpen; + final VoidCallback onToggleMembers; + final VoidCallback onOpenSearch; + final VoidCallback onTogglePinned; + + const _DmExpandingHeader({ + super.key, + required this.room, + required this.roomId, + required this.pt, + required this.expanded, + required this.onToggle, + required this.membersOpen, + required this.pinnedOpen, + required this.onToggleMembers, + required this.onOpenSearch, + required this.onTogglePinned, + }); + + @override + ConsumerState<_DmExpandingHeader> createState() => + _DmExpandingHeaderState(); +} + +class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> { + static const _animDuration = Duration(milliseconds: 280); + static const _animCurve = Curves.easeOutCubic; + + Profile? _profile; + String? _bannerMxcUri; + String? _statusMsg; + bool _online = false; + + String? get _partnerId => widget.room.directChatMatrixID; + + @override + void initState() { + super.initState(); + // Direkt laden, damit das Banner beim ersten Aufklappen schon da ist. + _load(); + } + + @override + void didUpdateWidget(_DmExpandingHeader old) { + super.didUpdateWidget(old); + if (old.room.id != widget.room.id) { + _profile = null; + _bannerMxcUri = null; + _statusMsg = null; + _online = false; + _load(); + } + } + + Future _load() async { + final userId = _partnerId; + if (userId == null) return; + final client = widget.room.client; + // Race-Guard: Beim schnellen DM-Wechsel dürfen verspätete Antworten des + // VORHERIGEN Partners das Profil des aktuellen nicht überschreiben. + bool stillCurrent() => mounted && _partnerId == userId; + try { + final p = await client.getProfileFromUserId(userId); + if (stillCurrent()) setState(() => _profile = p); + } catch (_) {} + try { + final p = await client.fetchCurrentPresence(userId); + final last = p.lastActiveTimestamp; + final fresh = p.currentlyActive == true || + (last != null && + DateTime.now().difference(last) < const Duration(minutes: 5)); + if (stillCurrent()) { + setState(() { + _online = p.presence == PresenceType.online && fresh; + _statusMsg = p.statusMsg; + }); + } + } catch (_) {} + // Banner aus dem Profil-Custom-Field (io.pyramid.profile.banner_url). + try { + final apiUri = client.homeserver!.replace( + path: '/_matrix/client/v3/profile/${Uri.encodeComponent(userId)}', + ); + final resp = await client.httpClient.get( + apiUri, + headers: {'Authorization': 'Bearer ${client.accessToken}'}, + ); + if (resp.statusCode == 200) { + final data = jsonDecode(resp.body) as Map; + final url = data['io.pyramid.profile.banner_url'] as String?; + if (url != null && stillCurrent()) { + setState(() => _bannerMxcUri = url); + } + } + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final expanded = widget.expanded; + final size = MediaQuery.sizeOf(context); + final isMobile = size.width < 900 || size.height < 500; + final client = ref.watch(matrixClientProvider).valueOrNull; + + final userId = _partnerId ?? ''; + final member = widget.room.unsafeGetUserFromMemoryOrFallback(userId); + final name = _profile?.displayName ?? + member.displayName ?? + widget.room.getLocalizedDisplayname(); + final avatarUrl = + _profile?.avatarUrl ?? member.avatarUrl ?? widget.room.avatar; + final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; + + // Im Querformat (wenig Höhe) kompakter aufklappen. + final expandedHeight = size.height < 500 ? 140.0 : 190.0; + final avatarSize = expanded + ? (size.height < 500 ? 72.0 : 96.0) + : 32.0; + + return AnimatedContainer( + duration: _animDuration, + curve: _animCurve, + height: expanded ? expandedHeight : 52, + decoration: BoxDecoration( + color: pt.bg1, + border: Border(bottom: BorderSide(color: pt.border)), + ), + clipBehavior: Clip.hardEdge, + child: Stack( + fit: StackFit.expand, + children: [ + // Banner als Hintergrund — blendet beim Aufklappen ein. + if (_bannerMxcUri != null && client != null) + Positioned.fill( + child: AnimatedOpacity( + opacity: expanded ? 1 : 0, + duration: _animDuration, + curve: Curves.easeOut, + child: MxcImage( + mxcUri: _bannerMxcUri!, + client: client, + fit: BoxFit.cover, + placeholder: const SizedBox.shrink(), + ), + ), + ), + // Scrim: links dunkel, damit Avatar/Text lesbar bleiben (Mockup). + if (_bannerMxcUri != null) + Positioned.fill( + child: AnimatedOpacity( + opacity: expanded ? 1 : 0, + duration: _animDuration, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + pt.bg1, + pt.bg1.withAlpha(210), + pt.bg1.withAlpha(0), + ], + stops: const [0.0, 0.32, 0.72], + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + if (isMobile) ...[ + GestureDetector( + onTap: () => + ref.read(activeRoomIdProvider.notifier).state = null, + child: + Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), + ), + const SizedBox(width: 12), + ], + // Klickbarer Profilbereich: Avatar + Name + Details + Expanded( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onToggle, + child: Row( + children: [ + // Avatar — skaliert mit (rund, wie im Mockup) + AnimatedContainer( + duration: _animDuration, + curve: _animCurve, + width: avatarSize, + height: avatarSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: pt.bg3, + ), + clipBehavior: Clip.antiAlias, + child: avatarUrl != null && client != null + ? MxcImage( + mxcUri: avatarUrl.toString(), + client: client, + fit: BoxFit.cover, + placeholder: _initialBox(initial, pt), + ) + : _initialBox(initial, pt), + ), + AnimatedContainer( + duration: _animDuration, + curve: _animCurve, + width: expanded ? 20 : 10, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name skaliert mit; Status blendet daneben ein + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: AnimatedDefaultTextStyle( + duration: _animDuration, + curve: _animCurve, + style: TextStyle( + color: pt.fg, + fontSize: expanded ? 24 : 15, + fontWeight: expanded + ? FontWeight.w700 + : FontWeight.w600, + ), + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + AnimatedOpacity( + opacity: expanded ? 1 : 0, + duration: _animDuration, + child: Padding( + padding: + const EdgeInsets.only(left: 8), + child: Text( + _online ? 'online' : 'offline', + style: TextStyle( + color: _online + ? pt.online + : pt.fgMuted, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + // Eingeklappt: "zuletzt online vor…", + // aufgeklappt: Handle + Biographie. + AnimatedCrossFade( + duration: _animDuration, + sizeCurve: _animCurve, + crossFadeState: expanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: client != null + ? _PresenceLine( + userId: _partnerId, + client: client, + pt: pt, + ) + : const SizedBox.shrink(), + secondChild: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + userId, + style: TextStyle( + color: pt.fgMuted, fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (_statusMsg != null && + _statusMsg!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + _statusMsg!, + style: TextStyle( + color: pt.fgMuted, + fontSize: 13, + height: 1.35), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 12), + // Aktionen — bleiben immer sichtbar + Row( + children: [ + PyrIconBtn( + size: 28, + icon: const Icon(Icons.phone_rounded), + tooltip: 'Voice call', + onPressed: () => _startCall(cam: false), + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.videocam_rounded), + tooltip: 'Video call', + onPressed: () => _startCall(cam: true), + ), + if (size.width > 700) + Container( + width: 1, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: pt.border, + ), + if (size.width > 800) + PyrIconBtn( + size: 28, + icon: const Icon(Icons.push_pin_rounded), + tooltip: 'Angepinnte Nachrichten', + active: widget.pinnedOpen, + onPressed: widget.onTogglePinned, + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.search_rounded), + tooltip: 'Search', + onPressed: widget.onOpenSearch, + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.group_rounded), + tooltip: 'Members', + active: widget.membersOpen, + onPressed: widget.onToggleMembers, + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _initialBox(String initial, PyramidTheme pt) => Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(6), + child: Text( + initial, + style: TextStyle( + color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700), + ), + ), + ), + ); + + void _startCall({required bool cam}) async { + ref.read(viewModeProvider.notifier).state = ViewMode.voice; + await PyramidVoipManager.instance.startCall(widget.room.id, video: cam); + } +} + +class _DmHeaderAvatar extends StatelessWidget { + final Room? room; + final String name; + final Client client; + + const _DmHeaderAvatar({required this.room, required this.name, required this.client}); + + @override + Widget build(BuildContext context) { + final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; + const size = 32.0; + return MxcAvatar( + mxcUri: room?.avatar, + client: client, + size: size, + borderRadius: BorderRadius.circular(size / 2), + placeholder: (_) => _InitialCircle(initial: initial, size: size), + ); + } +} + +class _InitialCircle extends StatelessWidget { + final String initial; + final double size; + const _InitialCircle({required this.initial, required this.size}); + + @override + Widget build(BuildContext context) { + const color = Color(0xFF06B6D4); + return Container( + width: size, + height: size, + decoration: const BoxDecoration(color: color, shape: BoxShape.circle), + child: Center( + child: Text(initial, + style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)), + ), + ); + } +} + +class _PresenceLine extends StatefulWidget { + final String? userId; + final Client client; + final PyramidTheme pt; + + const _PresenceLine({required this.userId, required this.client, required this.pt}); + + @override + State<_PresenceLine> createState() => _PresenceLineState(); +} + +class _PresenceLineState extends State<_PresenceLine> { + CachedPresence? _presence; + StreamSubscription? _sub; + // Debounce timer: delay "online" updates 4s to filter out background-sync + // false positives (push notifications briefly wake the recipient's client). + Timer? _onlineDebounce; + + @override + void initState() { + super.initState(); + _load(widget.userId); + } + + @override + void didUpdateWidget(_PresenceLine oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.userId != widget.userId) { + _sub?.cancel(); + _sub = null; + _onlineDebounce?.cancel(); + setState(() => _presence = null); + _load(widget.userId); + } + } + + @override + void dispose() { + _sub?.cancel(); + _onlineDebounce?.cancel(); + super.dispose(); + } + + // "online" only when the server's presence enum says online AND the last + // activity is recent. Using currentlyActive (the old logic) reported online + // long after the user left, because that flag lingers. + static bool _isOnline(CachedPresence p) { + if (p.presence != PresenceType.online) return false; + final last = p.lastActiveTimestamp; + return last == null || DateTime.now().difference(last).inSeconds < 90; + } + + void _applyPresence(CachedPresence p) { + _onlineDebounce?.cancel(); + if (_isOnline(p)) { + // Wait 4 seconds before committing "online" to filter single-sync spikes + // (a backgrounded client briefly reports online when a push wakes it). + _onlineDebounce = Timer(const Duration(seconds: 4), () { + if (mounted) setState(() => _presence = p); + }); + } else { + if (mounted) setState(() => _presence = p); + } + } + + Future _load(String? userId) async { + if (userId == null) return; + // Never show our own presence in a DM header + if (userId == widget.client.userID) return; + try { + final p = await widget.client.fetchCurrentPresence(userId); + if (mounted) _applyPresence(p); + } catch (_) {} + _sub = widget.client.onPresenceChanged.stream + .where((p) => p.userid == userId) + .listen((p) => _applyPresence(p)); + } + + @override + Widget build(BuildContext context) { + final p = _presence; + if (p == null) return const SizedBox.shrink(); + + String text; + Color color = widget.pt.fgDim; + if (_isOnline(p)) { + text = 'online'; + color = const Color(0xFF3BA55D); + } else if (p.lastActiveTimestamp != null) { + text = 'zuletzt online ${_relativeTime(p.lastActiveTimestamp!)}'; + } else { + return const SizedBox.shrink(); + } + + return Text( + text, + style: TextStyle(color: color, fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + String _relativeTime(DateTime t) { + final diff = DateTime.now().difference(t); + if (diff.inMinutes < 1) return 'gerade eben'; + if (diff.inMinutes < 60) return 'vor ${diff.inMinutes}min'; + if (diff.inHours < 24) return 'vor ${diff.inHours}h'; + if (diff.inDays < 7) return 'vor ${diff.inDays}d'; + if (diff.inDays < 365) return 'vor ${(diff.inDays / 7).floor()}w'; + return 'vor ${(diff.inDays / 365).floor()}j'; + } +} + +// Shows a slim banner when the client loses its connection to the homeserver. +// Debounced so brief transient sync errors don't flicker a banner. +class _ConnectionBanner extends ConsumerStatefulWidget { + const _ConnectionBanner(); + @override + ConsumerState<_ConnectionBanner> createState() => _ConnectionBannerState(); +} + +class _ConnectionBannerState extends ConsumerState<_ConnectionBanner> { + StreamSubscription? _sub; + Timer? _debounce; + bool _lost = false; + + @override + void initState() { + super.initState(); + _attach(); + } + + Future _attach() async { + try { + final client = await ref.read(matrixClientProvider.future); + _sub = client.onSyncStatus.stream.listen((s) { + if (s.status == SyncStatus.error) { + // Only surface after the error persists a few seconds. + _debounce ??= Timer(const Duration(seconds: 4), () { + _debounce = null; + if (mounted) setState(() => _lost = true); + }); + } else if (s.status == SyncStatus.finished || + s.status == SyncStatus.processing) { + _debounce?.cancel(); + _debounce = null; + if (_lost && mounted) setState(() => _lost = false); + } + }); + } catch (_) {} + } + + @override + void dispose() { + _sub?.cancel(); + _debounce?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: _lost + ? Container( + width: double.infinity, + color: pt.danger.withAlpha(36), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.cloud_off_rounded, size: 14, color: pt.danger), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Keine Verbindung zum Server – Wiederverbindung läuft…', + style: TextStyle(color: pt.danger, fontSize: 12, fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ); + } +} + +class _MessageList extends StatelessWidget { + final Timeline timeline; + final ScrollController scrollCtrl; + final String currentUserId; + final PyramidTheme pt; + final ValueChanged onReply; + final Set selectedIds; + final ValueChanged onToggleSelect; + final String? sessionFullyReadId; + final bool sessionHasUnread; + final bool unreadFading; + /// Attached to the group-level _UnreadDivider so ensureVisible can scroll to it. + final GlobalKey? unreadDividerKey; + /// Jump-to-message target (from search): scroll to + highlight this event. + final String? jumpEventId; + final GlobalKey? jumpKey; + final String? highlightEventId; + final bool highlightOn; + + const _MessageList({ + required this.timeline, + required this.scrollCtrl, + required this.currentUserId, + required this.pt, + required this.onReply, + required this.selectedIds, + required this.onToggleSelect, + this.sessionFullyReadId, + this.sessionHasUnread = false, + this.unreadFading = false, + this.unreadDividerKey, + this.jumpEventId, + this.jumpKey, + this.highlightEventId, + this.highlightOn = false, + }); + + @override + Widget build(BuildContext context) { + // Dedupe by eventId — during concurrent forward/backward pagination the + // timeline can transiently contain the same event twice, which would + // produce duplicate ListView keys and crash the sliver (indexOf assertion). + final seenIds = {}; + final events = timeline.events + .where(_isVisible) + .where((e) => seenIds.add(e.eventId)) + .toList(); + + if (events.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.forum_outlined, size: 48, color: pt.fgDim), + const SizedBox(height: 16), + Text( + 'No messages yet', + style: TextStyle(color: pt.fgMuted, fontSize: 14), + ), + ], + ), + ); + } + + final groups = _buildGroups(events); + final hasUnread = sessionHasUnread; + + // Resolve the effective fullyReadId: if the captured ID points to a + // non-visible event (reaction, edit, state) walk the timeline forward + // (toward older events) until we find the nearest visible one. + String? fullyReadId = sessionFullyReadId; + if (fullyReadId != null) { + final inGroups = groups.any((g) => g.any((e) => e.eventId == fullyReadId)); + if (!inGroups) { + final tlEvents = timeline.events; // newest-first + final idx = tlEvents.indexWhere((e) => e.eventId == fullyReadId); + if (idx >= 0) { + fullyReadId = null; + for (int i = idx; i < tlEvents.length; i++) { + if (_isVisible(tlEvents[i])) { + fullyReadId = tlEvents[i].eventId; + break; + } + } + } else { + fullyReadId = null; // event not even in loaded timeline + } + } + } + + // Read-receipt display — matching Element behaviour: + // • The newest unread own group shows ✓ (sent but not read) + // • The newest own group AT or BEFORE the partner's receipt shows ✓✓ + // • If those are the same group → just ✓✓ + final room = timeline.room; + + // Read receipts (Element-style): + // • ✓✓ at the single newest event another member has read (any sender) — + // moves next to the partner's own reply when they reply/read everything. + // • grey ✓ only on my newest message while it's still unread by them. + // timeline.events is newest-first → lower index = newer. + // + // Receipts können je nach Client in `global` ODER `mainThread` stehen + // (threaded read receipts) — beide zusammenführen, neuere ts gewinnt. + final mergedReceipts = {}; + for (final source in [ + room.receiptState.global.otherUsers, + room.receiptState.mainThread?.otherUsers ?? const {}, + ]) { + source.forEach((userId, data) { + final existing = mergedReceipts[userId]; + if (existing == null || data.ts > existing.ts) { + mergedReceipts[userId] = data; + } + }); + } + + int readIndex = -1; + int latestReceiptTs = 0; + mergedReceipts.forEach((userId, data) { + if (userId == currentUserId) return; + if (data.ts > latestReceiptTs) latestReceiptTs = data.ts; + var ri = timeline.events.indexWhere((e) => e.eventId == data.eventId); + // Receipt kann auf ein unsichtbares Event zeigen (Reaktion, Edit) — + // zum nächstälteren sichtbaren Event vorrücken, sonst geht der Marker + // verloren. + while (ri >= 0 && + ri < timeline.events.length && + !_isVisible(timeline.events[ri])) { + ri++; + } + if (ri >= timeline.events.length) ri = -1; + if (ri >= 0 && (readIndex < 0 || ri < readIndex)) readIndex = ri; + }); + final readEventIds = + readIndex >= 0 ? {timeline.events[readIndex].eventId} : {}; + + // Newest own visible message, and whether it's still unread (newer than the + // partner's read position, or no read position yet). + final newestOwnIndex = + timeline.events.indexWhere((e) => _isVisible(e) && e.senderId == currentUserId); + var ownUnread = newestOwnIndex >= 0 && (readIndex < 0 || newestOwnIndex < readIndex); + // Fallback: Das Receipt-Event liegt außerhalb des geladenen Fensters + // (z.B. sehr alte Nachricht) — wenn der Receipt-Zeitstempel jünger ist als + // meine neueste Nachricht, gilt sie trotzdem als gelesen. + if (ownUnread && + readIndex < 0 && + latestReceiptTs >= + timeline.events[newestOwnIndex].originServerTs.millisecondsSinceEpoch) { + readEventIds.add(timeline.events[newestOwnIndex].eventId); + ownUnread = false; + } + final lastOwnGroupKey = (ownUnread) + ? () { + for (final g in groups) { + if (g.first.senderId == currentUserId) return 'g:${g.first.eventId}'; + } + return null; + }() + : null; + + // Flatten groups + date dividers + unread divider into independent list items. + // itemFullyReadIds[i] is non-null only for a group item that contains the + // fullyRead event at a non-last position — MessageGroup renders an inline + // divider after that event. + final itemKeys = []; + final itemGroups = ?>[]; + final itemDates = []; + final itemUnread = []; + final itemFullyReadIds = []; + + bool unreadInserted = false; + + for (var i = 0; i < groups.length; i++) { + final group = groups[i]; + // Use the newest event (group.last after reversal) for date comparison. + final groupNewest = group.last.originServerTs; + final nextGroupNewest = i < groups.length - 1 ? groups[i + 1].last.originServerTs : null; + final isLastOfDay = nextGroupNewest == null || !_sameDay(groupNewest, nextGroupNewest); + + // Check if this group contains the effective fullyRead event. + final groupContainsFullyRead = fullyReadId != null && + group.any((e) => e.eventId == fullyReadId); + + if (!unreadInserted && hasUnread && groupContainsFullyRead) { + if (group.last.eventId == fullyReadId) { + // fullyRead is the NEWEST event in the group → the whole group is + // read. Insert a group-level divider between this group and the + // newer (lower-index) groups. + itemKeys.add('unread'); + itemGroups.add(null); + itemDates.add(null); + itemUnread.add(true); + itemFullyReadIds.add(null); + } + // Whether group-level or inline, we've now handled the marker. + unreadInserted = true; + } + + // Key based on oldest event (group.first) — stable identifier. + itemKeys.add('g:${group.first.eventId}'); + itemGroups.add(group); + itemDates.add(null); + itemUnread.add(false); + // Pass fullyReadId to the group only when the event is mid-group + // (not the last event) so MessageGroup can render an inline divider. + // groupContainsFullyRead already guarantees fullyReadId != null. + final passedFullyReadId = + (groupContainsFullyRead && group.last.eventId != fullyReadId) + ? fullyReadId + : null; + itemFullyReadIds.add(passedFullyReadId); + + if (isLastOfDay) { + final d = groupNewest; + itemKeys.add('d:${d.year}-${d.month}-${d.day}'); + itemGroups.add(null); + itemDates.add(d); + itemUnread.add(false); + itemFullyReadIds.add(null); + } + } + + // Fallback: fullyRead event is older than all loaded events — all visible + // messages are unread. Insert divider above the oldest loaded group (top of list). + if (!unreadInserted && hasUnread) { + itemKeys.add('unread'); + itemGroups.add(null); + itemDates.add(null); + itemUnread.add(true); + itemFullyReadIds.add(null); + } + + final keyIndex = { + for (var i = 0; i < itemKeys.length; i++) itemKeys[i]: i, + }; + + return ListView.builder( + controller: scrollCtrl, + reverse: true, + physics: const ClampingScrollPhysics(), + padding: const EdgeInsets.only(bottom: 8), + cacheExtent: 2500, + itemCount: itemKeys.length, + findChildIndexCallback: (Key key) { + // Only return an index when the key still maps to that exact slot — + // returning a stale/duplicate index trips the sliver indexOf assertion. + if (key is ValueKey) { + final idx = keyIndex[key.value]; + if (idx != null && idx < itemKeys.length && itemKeys[idx] == key.value) { + return idx; + } + } + return null; + }, + itemBuilder: (context, i) { + final key = ValueKey(itemKeys[i]); + if (itemUnread[i]) { + // Wrap in SizedBox so the list can use the ValueKey for optimisation + // while _UnreadDivider carries the GlobalKey for ensureVisible. + return SizedBox( + key: key, + child: _UnreadDivider(key: unreadDividerKey, pt: pt, fading: unreadFading), + ); + } + final date = itemDates[i]; + if (date != null) return DateDivider(key: key, date: date); + final group = itemGroups[i]!; + final groupKey = itemKeys[i]; + // Show the sending/sent indicator on the newest own group. + final showStatus = groupKey == lastOwnGroupKey; + return MessageGroup( + key: key, + events: group, + currentUserId: currentUserId, + room: room, + timeline: timeline, + onReply: () => onReply(group.last), + selectedIds: selectedIds, + onToggleSelect: onToggleSelect, + showStatusIndicator: showStatus, + readEventIds: readEventIds, + fullyReadId: itemFullyReadIds[i], + jumpEventId: jumpEventId, + jumpKey: jumpKey, + highlightEventId: highlightEventId, + highlightOn: highlightOn, + ); + }, + ); + } + + List> _buildGroups(List events) { + final groups = >[]; + List? current; + + for (final event in events) { + if (current == null || + current.first.senderId != event.senderId || + event.originServerTs.difference(current.last.originServerTs).abs() > + const Duration(minutes: 5)) { + current = [event]; + groups.add(current); + } else { + current.add(event); + } + } + // The timeline is newest-first, so each group is [newest, ..., oldest]. + // Reverse each group so messages render oldest-to-newest (top to bottom). + return groups.map((g) => g.reversed.toList()).toList(); + } + + bool _sameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + + static bool _isVisible(Event e) { + // NOTE: error-status events are kept visible so a failed send shows a + // "not sent" state with retry/delete instead of silently disappearing. + if (!{ + EventTypes.Message, + EventTypes.Sticker, + EventTypes.Encrypted, + }.contains(e.type)) { return false; } + if ({ + RelationshipTypes.edit, + RelationshipTypes.reaction, + }.contains(e.relationshipType)) { return false; } + if (e.type == EventTypes.Redaction) { return false; } + return true; + } +} + +class _SelectionHeader extends StatelessWidget { + final PyramidTheme pt; + final int count; + final Set selectedIds; + final Room? room; + final Timeline? timeline; + final String currentUserId; + final VoidCallback onClear; + final VoidCallback onReply; + + const _SelectionHeader({ + required this.pt, + required this.count, + required this.selectedIds, + required this.room, + required this.timeline, + required this.currentUserId, + required this.onClear, + required this.onReply, + }); + + List _resolveEvents() { + if (timeline == null) return []; + return selectedIds + .map((id) { + try { return timeline!.events.firstWhere((e) => e.eventId == id); } catch (_) { return null; } + }) + .whereType() + .toList(); + } + + void _copyAll() { + final texts = _resolveEvents().map((e) => e.body).join('\n'); + Clipboard.setData(ClipboardData(text: texts)); + } + + void _deleteAll(BuildContext context) { + for (final e in _resolveEvents()) { + if (e.senderId == currentUserId) { + e.redactEvent(reason: 'Vom Nutzer gelöscht').catchError((_) => null); + } + } + onClear(); + } + + void _pinAll() { + if (room == null) return; + final pinned = List.from( + room!.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [], + ); + for (final e in _resolveEvents()) { + if (!pinned.contains(e.eventId)) pinned.add(e.eventId); + } + room!.setPinnedEvents(pinned).catchError((_) => ''); + onClear(); + } + + + @override + Widget build(BuildContext context) { + final events = _resolveEvents(); + final canDelete = events.isNotEmpty && events.every((e) => e.senderId == currentUserId); + final isTextOnly = events.every((e) => e.messageType == 'm.text'); + + return Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: pt.accentSoft, + border: Border(bottom: BorderSide(color: pt.accent.withAlpha(80))), + ), + child: Row( + children: [ + IconButton( + onPressed: onClear, + icon: Icon(Icons.close_rounded, size: 20, color: pt.accent), + tooltip: 'Auswahl aufheben', + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, + ), + const SizedBox(width: 4), + Text( + '$count ausgewählt', + style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), + ), + const Spacer(), + if (count == 1) + _SelBtn(icon: Icons.reply_rounded, tooltip: 'Antworten', pt: pt, onTap: onReply), + if (isTextOnly) + _SelBtn(icon: Icons.copy_rounded, tooltip: 'Kopieren', pt: pt, onTap: _copyAll), + _SelBtn(icon: Icons.push_pin_outlined, tooltip: 'Anpinnen', pt: pt, onTap: _pinAll), + if (canDelete) + _SelBtn( + icon: Icons.delete_outline_rounded, + tooltip: 'Löschen', + pt: pt, + color: pt.danger, + onTap: () => _deleteAll(context), + ), + ], + ), + ); + } +} + +class _SelBtn extends StatelessWidget { + final IconData icon; + final String tooltip; + final PyramidTheme pt; + final Color? color; + final VoidCallback onTap; + + const _SelBtn({required this.icon, required this.tooltip, required this.pt, required this.onTap, this.color}); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onTap, + icon: Icon(icon, size: 20, color: color ?? pt.fg), + tooltip: tooltip, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, + ); + } +} + +class _UnreadDivider extends StatelessWidget { + final PyramidTheme pt; + final bool fading; + const _UnreadDivider({super.key, required this.pt, this.fading = false}); + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + opacity: fading ? 0.0 : 1.0, + duration: const Duration(milliseconds: 700), + curve: Curves.easeOut, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 4), + child: Row( + children: [ + Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + 'Neue Nachrichten', + style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600), + ), + ), + Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)), + ], + ), + ), + ); + } +} + +// ─── Typing indicator ───────────────────────────────────────────────────────── + +class _TypingIndicator extends StatefulWidget { + final Room room; + final PyramidTheme pt; + const _TypingIndicator({required this.room, required this.pt}); + + @override + State<_TypingIndicator> createState() => _TypingIndicatorState(); +} + +class _TypingIndicatorState extends State<_TypingIndicator> { + StreamSubscription? _sub; + List _typing = []; + + StreamSubscription _listenTyping(Room room) { + // client.onSync fires after every sync — cheap to filter for typing events. + return room.client.onSync.stream.listen((_) { + if (!mounted) return; + final typing = room.typingUsers + .where((u) => u.id != room.client.userID) + .toList(); + if (typing.length != _typing.length || + !typing.every((u) => _typing.any((t) => t.id == u.id))) { + setState(() => _typing = typing); + } + }); + } + + @override + void initState() { + super.initState(); + _sub = _listenTyping(widget.room); + } + + @override + void didUpdateWidget(_TypingIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.room.id != widget.room.id) { + _sub?.cancel(); + setState(() => _typing = []); + _sub = _listenTyping(widget.room); + } + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_typing.isEmpty) return const SizedBox.shrink(); + final pt = widget.pt; + final names = _typing.map((u) => u.calcDisplayname()).toList(); + final label = names.length == 1 + ? '${names[0]} tippt…' + : names.length == 2 + ? '${names[0]} und ${names[1]} tippen…' + : '${names[0]} und ${names.length - 1} weitere tippen…'; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 0), + child: Row( + children: [ + _TypingDots(color: pt.fgDim), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + style: TextStyle(color: pt.fgDim, fontSize: 12, fontStyle: FontStyle.italic), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _TypingDots extends StatefulWidget { + final Color color; + const _TypingDots({required this.color}); + + @override + State<_TypingDots> createState() => _TypingDotsState(); +} + +class _TypingDotsState extends State<_TypingDots> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (context, child) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + // Each dot peaks at a different phase. + final phase = ((_ctrl.value * 3) - i).clamp(0.0, 1.0); + final opacity = (phase < 0.5 ? phase * 2 : (1 - phase) * 2).clamp(0.3, 1.0); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Opacity( + opacity: opacity, + child: Container( + width: 4, + height: 4, + decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle), + ), + ), + ); + }), + ); + }, + ); + } +} + +// ─── Drag-and-drop overlay ──────────────────────────────────────────────────── + +class _DragOverlay extends StatefulWidget { + final PyramidTheme pt; + final bool active; + const _DragOverlay({required this.pt, required this.active}); + + @override + State<_DragOverlay> createState() => _DragOverlayState(); +} + +class _DragOverlayState extends State<_DragOverlay> + with SingleTickerProviderStateMixin { + // Sanftes Auf-und-ab des Icons, solange das Overlay aktiv ist. + late final AnimationController _bob = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + + @override + void initState() { + super.initState(); + if (widget.active) _bob.repeat(reverse: true); + } + + @override + void didUpdateWidget(_DragOverlay old) { + super.didUpdateWidget(old); + if (widget.active && !old.active) { + _bob.repeat(reverse: true); + } else if (!widget.active && old.active) { + _bob.stop(); + } + } + + @override + void dispose() { + _bob.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Container( + color: pt.bg0.withAlpha(200), + child: Center( + child: AnimatedScale( + scale: widget.active ? 1.0 : 0.88, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutBack, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: pt.accent, width: 2), + boxShadow: [ + BoxShadow( + color: pt.accent.withAlpha(60), + blurRadius: 32, + spreadRadius: 4, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _bob, + builder: (_, child) => Transform.translate( + offset: Offset( + 0, -7 * Curves.easeInOut.transform(_bob.value)), + child: child, + ), + child: + Icon(Icons.upload_file_rounded, size: 56, color: pt.accent), + ), + const SizedBox(height: 16), + Text( + 'Datei hier ablegen', + style: TextStyle( + color: pt.fg, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Loslassen zum Hochladen', + style: TextStyle(color: pt.fgMuted, fontSize: 14), + ), + ], + ), + ), + ), + ), + ); + } +} + +// Placeholder shown when no room is selected +class NoChatSelected extends StatelessWidget { + const NoChatSelected({super.key}); + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return Container( + color: pt.bg1, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.forum_outlined, size: 64, color: pt.fgDim), + const SizedBox(height: 16), + Text( + 'Select a room to start chatting', + style: TextStyle(color: pt.fgMuted, fontSize: 16), + ), + const SizedBox(height: 8), + Text( + 'Choose from the sidebar on the left', + style: TextStyle(color: pt.fgDim, fontSize: 13), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/chat/document_viewer.dart b/lib/features/chat/document_viewer.dart new file mode 100644 index 0000000..57c8c7a --- /dev/null +++ b/lib/features/chat/document_viewer.dart @@ -0,0 +1,1773 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pdfx/pdfx.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:url_launcher/url_launcher.dart'; + +// ─── Static render cache ────────────────────────────────────────────────────── +// Survives widget dispose/recreate (e.g. scroll in chat list). +// Key: filename + file-size-bytes + page + render-dimensions. +// Evicts the oldest entry (FIFO) when the limit is reached. + +class _RenderCache { + static const _max = 80; + static final _data = {}; + + static String _k(String name, int sz, int page, int w, int h) => + '$name\x00$sz\x00$page\x00${w}x$h'; + + static Uint8List? get(String name, int sz, int page, int w, int h) => + _data[_k(name, sz, page, w, h)]; + + static void put(String name, int sz, int page, int w, int h, Uint8List img) { + if (_data.length >= _max) _data.remove(_data.keys.first); + _data[_k(name, sz, page, w, h)] = img; + } +} + +// ─── Inline chat preview ────────────────────────────────────────────────────── +// Used by _DocPreviewContent in message_group.dart. +// Shows the first page of a PDF as an image with page navigation arrows. +// Tap → opens the full DocumentViewer overlay. + +class PdfInlinePreview extends StatefulWidget { + final Uint8List bytes; + final String filename; + final PyramidTheme pt; + final String senderName; + final VoidCallback? onDownload; + + const PdfInlinePreview({ + super.key, + required this.bytes, + required this.filename, + required this.pt, + required this.senderName, + this.onDownload, + }); + + @override + State createState() => _PdfInlinePreviewState(); +} + +class _PdfInlinePreviewState extends State { + PdfDocument? _doc; + int _currentPage = 1; + int _pageCount = 0; + double? _pageAspect; // width/height + final Map _cache = {}; + bool _loading = true; + String? _error; + bool _rendering = false; + + static const _previewW = 280.0; + static const _renderScale = 2.0; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + try { + final doc = await PdfDocument.openData(widget.bytes); + if (!mounted) { await doc.close(); return; } + _doc = doc; + _pageCount = doc.pagesCount; + setState(() => _loading = false); + await _ensurePage(1); + } catch (e) { + if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; }); + } + } + + Future _ensurePage(int n) async { + if (_cache.containsKey(n) || _rendering) return; + _rendering = true; + try { + final page = await _doc!.getPage(n); + if (mounted && _pageAspect == null) { + setState(() => _pageAspect = page.width / page.height); + } + final rw = (page.width * _renderScale).round(); + final rh = (page.height * _renderScale).round(); + final hit = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh); + if (hit != null) { + await page.close(); + if (mounted) setState(() => _cache[n] = hit); + return; + } + final img = await page.render( + width: page.width * _renderScale, + height: page.height * _renderScale, + format: PdfPageImageFormat.jpeg, + backgroundColor: '#ffffff', + ); + await page.close(); + if (img?.bytes != null) { + _RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, img!.bytes); + } + if (mounted) setState(() => _cache[n] = img?.bytes); + } catch (_) { + _cache[n] = null; + } finally { + _rendering = false; + } + } + + void _goTo(int page) { + if (page < 1 || page > _pageCount) return; + setState(() => _currentPage = page); + _ensurePage(page); + _ensurePage((page + 1).clamp(1, _pageCount)); + } + + void _openFull() { + DocumentViewer.show( + context, + filename: widget.filename, + bytes: widget.bytes, + senderName: widget.senderName, + initialPage: _currentPage, + ); + } + + @override + void dispose() { + _doc?.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final radius = BorderRadius.circular(pt.rBase); + + if (_loading) { + return Container( + width: _previewW, + // Match the typical loaded A4 height so the preview doesn't jump (and + // shift the scroll position) once the first page finishes rendering. + height: (_previewW / 0.707).clamp(160.0, 400.0), + margin: const EdgeInsets.only(top: 4, bottom: 4), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: radius, + border: Border.all(color: pt.border), + ), + child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), + ); + } + + if (_error != null) { + return _FileFallback(filename: widget.filename, pt: pt, onDownload: widget.onDownload); + } + + final pageBytes = _cache[_currentPage]; + final aspect = _pageAspect ?? 0.707; // A4 fallback + final imgH = (_previewW / aspect).clamp(160.0, 400.0); + + return Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + GestureBinding.instance.pointerSignalResolver.register(event, (resolved) { + final dy = (resolved as PointerScrollEvent).scrollDelta.dy; + if (dy > 0) { _goTo(_currentPage + 1); } + else if (dy < 0) { _goTo(_currentPage - 1); } + }); + } + }, + child: GestureDetector( + onTap: _openFull, + onHorizontalDragEnd: (d) { + if ((d.primaryVelocity ?? 0) < -200) _goTo(_currentPage + 1); + if ((d.primaryVelocity ?? 0) > 200) _goTo(_currentPage - 1); + }, + child: Container( + margin: const EdgeInsets.only(top: 4, bottom: 4), + width: _previewW, + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all(color: pt.border), + ), + clipBehavior: Clip.hardEdge, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Page image + Container( + width: _previewW, + height: imgH, + color: Colors.white, + child: pageBytes != null + ? Image.memory(pageBytes, width: _previewW, height: imgH, fit: BoxFit.cover, alignment: Alignment.topCenter) + : Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), + ), + // Footer + Container( + color: pt.bg2, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + child: Row( + children: [ + Icon(Icons.picture_as_pdf_outlined, size: 14, color: pt.fgDim), + const SizedBox(width: 6), + Expanded( + child: Text( + widget.filename, + style: TextStyle(color: pt.fg, fontSize: 12, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + if (_pageCount > 1) ...[ + _NavArrow( + icon: Icons.chevron_left_rounded, + enabled: _currentPage > 1, + pt: pt, + onTap: () => _goTo(_currentPage - 1), + ), + Text( + '$_currentPage/$_pageCount', + style: TextStyle(color: pt.fgMuted, fontSize: 11, fontFeatures: const [FontFeature.tabularFigures()]), + ), + _NavArrow( + icon: Icons.chevron_right_rounded, + enabled: _currentPage < _pageCount, + pt: pt, + onTap: () => _goTo(_currentPage + 1), + ), + ], + if (widget.onDownload != null) ...[ + const SizedBox(width: 4), + GestureDetector( + onTap: widget.onDownload, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon(Icons.download_rounded, size: 16, color: pt.fgMuted), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _NavArrow extends StatelessWidget { + final IconData icon; + final bool enabled; + final PyramidTheme pt; + final VoidCallback onTap; + const _NavArrow({required this.icon, required this.enabled, required this.pt, required this.onTap}); + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: enabled ? onTap : null, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Icon(icon, size: 18, color: enabled ? pt.fg : pt.fgDim), + ), + ); +} + +class _FileFallback extends StatelessWidget { + final String filename; + final PyramidTheme pt; + final VoidCallback? onDownload; + const _FileFallback({required this.filename, required this.pt, this.onDownload}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(top: 4, bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + constraints: const BoxConstraints(maxWidth: 300), + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)), + child: Icon(Icons.picture_as_pdf_outlined, size: 20, color: pt.accent), + ), + const SizedBox(width: 10), + Expanded( + child: Text(filename, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), maxLines: 2, overflow: TextOverflow.ellipsis), + ), + if (onDownload != null) + IconButton( + onPressed: onDownload, + icon: Icon(Icons.download_rounded, size: 20, color: pt.fgMuted), + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: EdgeInsets.zero, + ), + ], + ), + ); + } +} + +// ─── Full overlay viewer ────────────────────────────────────────────────────── + +class DocumentViewer extends StatelessWidget { + final String filename; + final Uint8List bytes; + final String senderName; + final int initialPage; + + const DocumentViewer({ + super.key, + required this.filename, + required this.bytes, + required this.senderName, + this.initialPage = 1, + }); + + static bool supportsPreview(String filename) => + _detectType(filename) != _DocType.unknown; + + /// True for formats rendered inline in chat (pdf/svg types, including ai/eps). + static bool isInlinePreviewable(String filename) { + final t = _detectType(filename); + return t == _DocType.pdf || t == _DocType.svg; + } + + /// True for formats that show a card inline in chat (needs download-on-tap). + static bool showsInlineCard(String filename) { + final t = _detectType(filename); + return t == _DocType.proprietary || t == _DocType.psdThumb || t == _DocType.zipPreview; + } + + /// Kept for compatibility — same as showsInlineCard. + static bool isProprietaryFormat(String filename) => showsInlineCard(filename); + + static Future show( + BuildContext context, { + required String filename, + required Uint8List bytes, + required String senderName, + int initialPage = 1, + }) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Schließen', + barrierColor: Colors.black.withAlpha(178), + transitionDuration: const Duration(milliseconds: 180), + transitionBuilder: (ctx, anim, a, child) => + FadeTransition(opacity: anim, child: child), + pageBuilder: (ctx, a, b) => DocumentViewer( + filename: filename, + bytes: bytes, + senderName: senderName, + initialPage: initialPage, + ), + ); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final type = _detectType(filename); + final size = MediaQuery.sizeOf(context); + final maxW = (size.width - 48).clamp(300.0, 1100.0); + final maxH = (size.height - 48).clamp(300.0, 900.0); + + return Material( + type: MaterialType.transparency, + child: Center( + child: Container( + width: maxW, + height: maxH, + decoration: BoxDecoration( + color: pt.bg0, + borderRadius: BorderRadius.circular(pt.rLg), + border: Border.all(color: pt.border), + boxShadow: [BoxShadow(color: Colors.black.withAlpha(80), blurRadius: 32, offset: const Offset(0, 8))], + ), + clipBehavior: Clip.hardEdge, + child: Column( + children: [ + _DocHeader(filename: filename, senderName: senderName, pt: pt), + Expanded( + child: _DocContent( + filename: filename, + bytes: bytes, + type: type, + pt: pt, + initialPage: initialPage, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ─── Header ────────────────────────────────────────────────────────────────── + +class _DocHeader extends StatelessWidget { + final String filename; + final String senderName; + final PyramidTheme pt; + const _DocHeader({required this.filename, required this.senderName, required this.pt}); + + @override + Widget build(BuildContext context) { + final ext = filename.contains('.') ? filename.split('.').last.toUpperCase() : ''; + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), + decoration: BoxDecoration( + color: pt.bg1, + border: Border(bottom: BorderSide(color: pt.border)), + ), + child: Row( + children: [ + _ExtBadge(ext: ext, pt: pt), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(filename, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis), + Text(senderName, style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: Icon(Icons.close_rounded, color: pt.fgMuted, size: 20), + tooltip: 'Schließen', + ), + ], + ), + ); + } +} + +class _ExtBadge extends StatelessWidget { + final String ext; + final PyramidTheme pt; + const _ExtBadge({required this.ext, required this.pt}); + + @override + Widget build(BuildContext context) { + final label = ext.length > 4 ? ext.substring(0, 4) : ext; + return Container( + width: 44, height: 44, + decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)), + child: Center( + child: Text(label, style: TextStyle(color: pt.accent, fontSize: label.length > 3 ? 10 : 12, fontWeight: FontWeight.w700)), + ), + ); + } +} + +// ─── Content dispatcher ─────────────────────────────────────────────────────── + +class _DocContent extends StatelessWidget { + final String filename; + final Uint8List bytes; + final _DocType type; + final PyramidTheme pt; + final int initialPage; + const _DocContent({required this.filename, required this.bytes, required this.type, required this.pt, required this.initialPage}); + + @override + Widget build(BuildContext context) { + return switch (type) { + _DocType.pdf => _PdfFullViewer(bytes: bytes, filename: filename, pt: pt, initialPage: initialPage), + _DocType.svg => _SvgViewer(bytes: bytes, pt: pt), + _DocType.markdown => _MarkdownViewer(bytes: bytes, pt: pt), + _DocType.text => _TextViewer(bytes: bytes, pt: pt, mono: false), + _DocType.code => _TextViewer(bytes: bytes, pt: pt, mono: true), + _DocType.office => _OfficeTextViewer(bytes: bytes, filename: filename, pt: pt), + _DocType.archive => _ArchiveViewer(bytes: bytes, filename: filename, pt: pt), + _DocType.psdThumb => _PsdThumbViewer(bytes: bytes, filename: filename, pt: pt), + _DocType.zipPreview => _ZipPreviewViewer(bytes: bytes, filename: filename, pt: pt), + _DocType.proprietary => _ProprietaryViewer(filename: filename, bytes: bytes, pt: pt), + _DocType.unknown => _UnsupportedViewer(pt: pt), + }; + } +} + +// ─── PDF full viewer with thumbnail strip ──────────────────────────────────── + +class _PdfFullViewer extends StatefulWidget { + final Uint8List bytes; + final String filename; + final PyramidTheme pt; + final int initialPage; + const _PdfFullViewer({required this.bytes, required this.filename, required this.pt, required this.initialPage}); + + @override + State<_PdfFullViewer> createState() => _PdfFullViewerState(); +} + +class _PdfFullViewerState extends State<_PdfFullViewer> { + late final PdfController _ctrl; + PdfDocument? _thumbDoc; + final Map _thumbs = {}; + int _pageCount = 0; + int _currentPage = 1; + bool _animating = false; + + @override + void initState() { + super.initState(); + _currentPage = widget.initialPage; + _ctrl = PdfController( + document: PdfDocument.openData(widget.bytes), + initialPage: widget.initialPage, + ); + _ctrl.pageListenable.addListener(_onPage); + _initThumbs(); + } + + Future _initThumbs() async { + final doc = await PdfDocument.openData(widget.bytes); + if (!mounted) { await doc.close(); return; } + _thumbDoc = doc; + if (!mounted) { await doc.close(); _thumbDoc = null; return; } + setState(() => _pageCount = doc.pagesCount); + for (var i = 1; i <= doc.pagesCount; i++) { + _renderThumb(i); + } + } + + Future _renderThumb(int n) async { + final doc = _thumbDoc; + if (doc == null) return; + try { + final page = await doc.getPage(n); + final rw = (page.width * 0.12).round(); + final rh = (page.height * 0.12).round(); + final hit = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh); + if (hit != null) { + await page.close(); + if (mounted) setState(() => _thumbs[n] = hit); + return; + } + final img = await page.render( + width: page.width * 0.12, + height: page.height * 0.12, + format: PdfPageImageFormat.jpeg, + backgroundColor: '#ffffff', + ); + await page.close(); + if (img?.bytes != null) { + _RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, img!.bytes); + } + if (mounted) setState(() => _thumbs[n] = img?.bytes); + } catch (_) { + _thumbs[n] = null; + } + } + + void _onPage() => setState(() => _currentPage = _ctrl.page); + + void _onScrollPage(int delta) { + if (_animating) return; + final pageCount = _ctrl.pagesCount ?? _pageCount; + if (pageCount == 0) return; + final target = (_currentPage + delta).clamp(1, pageCount); + if (target == _currentPage) return; + _animating = true; + _ctrl.animateToPage( + target, + duration: const Duration(milliseconds: 280), + curve: Curves.easeInOut, + ).then((_) { if (mounted) setState(() => _animating = false); }); + } + + @override + void dispose() { + _ctrl.pageListenable.removeListener(_onPage); + _ctrl.dispose(); + _thumbDoc?.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Column( + children: [ + Expanded( + child: Stack( + children: [ + Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + if (event.scrollDelta.dy > 0) { _onScrollPage(1); } + else if (event.scrollDelta.dy < 0) { _onScrollPage(-1); } + } + }, + child: PdfView( + controller: _ctrl, + scrollDirection: Axis.vertical, + physics: const NeverScrollableScrollPhysics(), + backgroundDecoration: BoxDecoration(color: pt.bg0), + onPageChanged: (p) => setState(() => _currentPage = p), + // Render at high resolution in PNG: sharper text/line-art and no + // JPEG chroma artifacts (matters most for AI/EPS/vector content), + // and gives headroom for the InteractiveViewer zoom. + renderer: (page) => page.render( + width: page.width * 4, + height: page.height * 4, + format: PdfPageImageFormat.png, + backgroundColor: '#ffffff', + ), + builders: PdfViewBuilders( + options: const DefaultBuilderOptions(), + documentLoaderBuilder: (ctx) => Center( + child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent), + ), + errorBuilder: (ctx, error) => _ErrorView(message: error.toString(), pt: pt), + ), + ), + ), + // Launch the immersive fullscreen page viewer. + Positioned( + top: 8, + right: 8, + child: Material( + color: pt.bg0.withAlpha(200), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rSm), + side: BorderSide(color: pt.border), + ), + child: IconButton( + tooltip: 'Vollbild', + icon: Icon(Icons.fullscreen_rounded, size: 22, color: pt.fg), + onPressed: () => _PdfFullscreenViewer.show( + context, + bytes: widget.bytes, + filename: widget.filename, + initialPage: _currentPage, + ), + ), + ), + ), + ], + ), + ), + if (_pageCount > 1) _ThumbnailStrip( + pageCount: _pageCount, + currentPage: _currentPage, + thumbs: _thumbs, + pt: pt, + onTap: (p) { + _ctrl.jumpToPage(p); + setState(() => _currentPage = p); + }, + ), + ], + ); + } +} + +// ─── Immersive fullscreen PDF viewer ───────────────────────────────────────── +// Launched from the enlarged preview. Per-page high-res render, pan via drag, +// zoom via +/− buttons (20%/click) and Ctrl+wheel, page nav via wheel/arrows. + +Future _saveBytes(BuildContext context, Uint8List bytes, String filename) async { + try { + Directory? dir; + if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + dir = await getDownloadsDirectory(); + } + dir ??= await getTemporaryDirectory(); + final ext = filename.contains('.') ? filename.split('.').last : 'bin'; + final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext'; + final file = File('${dir.path}/$name'); + await file.writeAsBytes(bytes); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gespeichert: ${file.path}'), duration: const Duration(seconds: 4)), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } +} + +class _PdfFullscreenViewer extends StatefulWidget { + final Uint8List bytes; + final String filename; + final int initialPage; + const _PdfFullscreenViewer({required this.bytes, required this.filename, required this.initialPage}); + + static Future show(BuildContext context, { + required Uint8List bytes, + required String filename, + required int initialPage, + }) { + return Navigator.of(context, rootNavigator: true).push(PageRouteBuilder( + opaque: true, + barrierColor: Colors.black, + transitionDuration: const Duration(milliseconds: 180), + pageBuilder: (_, __, ___) => + _PdfFullscreenViewer(bytes: bytes, filename: filename, initialPage: initialPage), + transitionsBuilder: (_, anim, __, child) => FadeTransition(opacity: anim, child: child), + )); + } + + @override + State<_PdfFullscreenViewer> createState() => _PdfFullscreenViewerState(); +} + +class _PdfFullscreenViewerState extends State<_PdfFullscreenViewer> { + PdfDocument? _doc; + final _tc = TransformationController(); + final _viewerKey = GlobalKey(); + int _page = 1; + int _count = 0; + Uint8List? _img; + bool _loading = true; + bool _rendering = false; + bool _uiVisible = true; + + bool get _isMobile => Platform.isAndroid || Platform.isIOS; + + static const _minScale = 1.0; + static const _maxScale = 8.0; + static const _renderScale = 5.0; // high-res page render for crisp zoom + + @override + void initState() { + super.initState(); + _page = widget.initialPage; + _open(); + } + + Future _open() async { + try { + final doc = await PdfDocument.openData(widget.bytes); + if (!mounted) { await doc.close(); return; } + _doc = doc; + _count = doc.pagesCount; + await _render(_page); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + Future _render(int n) async { + final doc = _doc; + if (doc == null || _rendering) return; + _rendering = true; + if (mounted) setState(() => _loading = true); + try { + final page = await doc.getPage(n); + final rw = (page.width * _renderScale).round(); + final rh = (page.height * _renderScale).round(); + var bytes = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh); + if (bytes == null) { + final img = await page.render( + width: page.width * _renderScale, + height: page.height * _renderScale, + format: PdfPageImageFormat.png, + backgroundColor: '#ffffff', + ); + bytes = img?.bytes; + if (bytes != null) { + _RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, bytes); + } + } + await page.close(); + if (mounted) setState(() { _img = bytes; _loading = false; }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } finally { + _rendering = false; + } + } + + void _goto(int n) { + final t = n.clamp(1, _count); + if (t == _page) return; + _tc.value = Matrix4.identity(); // reset zoom on page change + setState(() => _page = t); + _render(t); + } + + // factor 1.2 = +20%, 1/1.2 = −20% + void _zoom(double factor) { + final size = _viewerKey.currentContext?.size; + if (size == null) return; + final current = _tc.value.getMaxScaleOnAxis(); + final target = (current * factor).clamp(_minScale, _maxScale); + final applied = target / current; + if ((applied - 1).abs() < 0.001) return; + final cx = size.width / 2, cy = size.height / 2; + final m = _tc.value.clone() + ..translate(cx, cy) + ..scale(applied) + ..translate(-cx, -cy); + setState(() => _tc.value = m); + } + + @override + void dispose() { + _doc?.close(); + _tc.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + // Single tap on the page (not on a control) toggles the UI. + behavior: HitTestBehavior.translucent, + onTap: () => setState(() => _uiVisible = !_uiVisible), + child: Listener( + onPointerSignal: (e) { + if (e is! PointerScrollEvent) return; + final ctrl = HardwareKeyboard.instance.isControlPressed || + HardwareKeyboard.instance.isMetaPressed; + if (ctrl) { + _zoom(e.scrollDelta.dy < 0 ? 1.2 : 1 / 1.2); + } else { + _goto(e.scrollDelta.dy > 0 ? _page + 1 : _page - 1); + } + }, + child: _img == null + ? Center( + child: _loading + ? CircularProgressIndicator(color: pt.accent) + : const Icon(Icons.error_outline_rounded, color: Colors.white54, size: 40), + ) + : InteractiveViewer( + key: _viewerKey, + transformationController: _tc, + minScale: _minScale, + maxScale: _maxScale, + // Pinch-zoom on mobile only. On desktop the built-in + // scroll-wheel zoom is disabled so plain scrolling + // pages instead; zoom is Ctrl+wheel and the +/− buttons. + scaleEnabled: _isMobile, + child: Center( + child: Image.memory(_img!, fit: BoxFit.contain, gaplessPlayback: true), + ), + ), + ), + ), + ), + + // Right-side control column + if (_uiVisible) + Positioned( + right: 10, + top: 0, + bottom: 0, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _FsBtn(icon: Icons.add_rounded, tooltip: 'Vergrößern', pt: pt, onTap: () => _zoom(1.2)), + const SizedBox(height: 8), + _FsBtn(icon: Icons.remove_rounded, tooltip: 'Verkleinern', pt: pt, onTap: () => _zoom(1 / 1.2)), + const SizedBox(height: 8), + _FsBtn(icon: Icons.download_rounded, tooltip: 'Herunterladen', pt: pt, + onTap: () => _saveBytes(context, widget.bytes, widget.filename)), + const SizedBox(height: 8), + _FsBtn(icon: Icons.fullscreen_exit_rounded, tooltip: 'Vollbild beenden', pt: pt, + onTap: () => Navigator.of(context).maybePop()), + ], + ), + ), + ), + + // Bottom-center page navigation + if (_uiVisible && _count > 1) + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withAlpha(160), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white24), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _FsBtn(icon: Icons.chevron_left_rounded, tooltip: 'Vorherige', pt: pt, + enabled: _page > 1, onTap: () => _goto(_page - 1), flat: true), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text('$_page / $_count', + style: const TextStyle(color: Colors.white, fontSize: 13, + fontFeatures: [FontFeature.tabularFigures()])), + ), + _FsBtn(icon: Icons.chevron_right_rounded, tooltip: 'Nächste', pt: pt, + enabled: _page < _count, onTap: () => _goto(_page + 1), flat: true), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _FsBtn extends StatelessWidget { + final IconData icon; + final String tooltip; + final PyramidTheme pt; + final VoidCallback onTap; + final bool enabled; + final bool flat; + const _FsBtn({required this.icon, required this.tooltip, required this.pt, + required this.onTap, this.enabled = true, this.flat = false}); + + @override + Widget build(BuildContext context) { + final btn = Material( + color: flat ? Colors.transparent : Colors.black.withAlpha(160), + shape: flat + ? const CircleBorder() + : RoundedRectangleBorder(borderRadius: BorderRadius.circular(10), side: const BorderSide(color: Colors.white24)), + child: IconButton( + tooltip: tooltip, + icon: Icon(icon, color: enabled ? Colors.white : Colors.white30, size: 22), + onPressed: enabled ? onTap : null, + ), + ); + return btn; + } +} + +class _ThumbnailStrip extends StatefulWidget { + final int pageCount; + final int currentPage; + final Map thumbs; + final PyramidTheme pt; + final ValueChanged onTap; + const _ThumbnailStrip({required this.pageCount, required this.currentPage, required this.thumbs, required this.pt, required this.onTap}); + + @override + State<_ThumbnailStrip> createState() => _ThumbnailStripState(); +} + +class _ThumbnailStripState extends State<_ThumbnailStrip> { + late final ScrollController _sc; + + @override + void initState() { + super.initState(); + _sc = ScrollController(); + } + + @override + void didUpdateWidget(_ThumbnailStrip old) { + super.didUpdateWidget(old); + if (old.currentPage != widget.currentPage) { + _scrollToPage(widget.currentPage); + } + } + + void _scrollToPage(int page) { + const thumbW = 60.0 + 8.0; // thumb + margin + final target = (page - 1) * thumbW - 80; + if (_sc.hasClients) { + _sc.animateTo( + target.clamp(0.0, _sc.position.maxScrollExtent), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + } + + @override + void dispose() { + _sc.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Container( + height: 88, + decoration: BoxDecoration( + color: pt.bg1, + border: Border(top: BorderSide(color: pt.border)), + ), + child: ListView.builder( + controller: _sc, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + itemCount: widget.pageCount, + itemBuilder: (ctx, i) { + final page = i + 1; + final isCurrent = page == widget.currentPage; + final bytes = widget.thumbs[page]; + return GestureDetector( + onTap: () => widget.onTap(page), + child: Container( + width: 52, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: isCurrent ? pt.accent : pt.border, + width: isCurrent ? 2 : 1, + ), + ), + clipBehavior: Clip.hardEdge, + child: bytes != null + ? Image.memory(bytes, fit: BoxFit.cover) + : Center( + child: Text('$page', style: TextStyle(color: pt.fgDim, fontSize: 10)), + ), + ), + ); + }, + ), + ); + } +} + +// ─── SVG viewer ─────────────────────────────────────────────────────────────── + +class _SvgViewer extends StatelessWidget { + final Uint8List bytes; + final PyramidTheme pt; + const _SvgViewer({required this.bytes, required this.pt}); + + @override + Widget build(BuildContext context) { + return Center( + child: InteractiveViewer( + minScale: 0.5, + maxScale: 10, + child: Padding( + padding: const EdgeInsets.all(24), + child: SvgPicture.memory(bytes), + ), + ), + ); + } +} + +// ─── Markdown viewer ────────────────────────────────────────────────────────── + +class _MarkdownViewer extends StatelessWidget { + final Uint8List bytes; + final PyramidTheme pt; + const _MarkdownViewer({required this.bytes, required this.pt}); + + @override + Widget build(BuildContext context) { + final String text; + try { + text = utf8.decode(bytes, allowMalformed: true); + } catch (e) { + return _ErrorView(message: e.toString(), pt: pt); + } + + return Markdown( + data: text, + selectable: true, + padding: const EdgeInsets.all(24), + styleSheet: MarkdownStyleSheet( + p: TextStyle(color: pt.fg, fontSize: 14, height: 1.65), + h1: TextStyle(color: pt.fg, fontSize: 22, fontWeight: FontWeight.w700), + h2: TextStyle(color: pt.fg, fontSize: 18, fontWeight: FontWeight.w600), + h3: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), + h4: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), + code: TextStyle(color: pt.accent, fontFamily: 'monospace', fontSize: 13, backgroundColor: pt.bg2), + codeblockDecoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(pt.rSm), + border: Border.all(color: pt.border), + ), + blockquoteDecoration: BoxDecoration( + border: Border(left: BorderSide(color: pt.accent, width: 3)), + color: pt.accentSoft, + ), + blockquotePadding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + a: TextStyle(color: pt.accent, decoration: TextDecoration.underline, decorationColor: pt.accent), + strong: TextStyle(color: pt.fg, fontWeight: FontWeight.w600), + em: TextStyle(color: pt.fg, fontStyle: FontStyle.italic), + listBullet: TextStyle(color: pt.fgMuted), + horizontalRuleDecoration: BoxDecoration(border: Border(top: BorderSide(color: pt.border))), + ), + onTapLink: (text, href, title) { + if (href != null) launchUrl(Uri.parse(href), mode: LaunchMode.externalApplication); + }, + ); + } +} + +// ─── Text / Code viewer ─────────────────────────────────────────────────────── + +class _TextViewer extends StatelessWidget { + final Uint8List bytes; + final PyramidTheme pt; + final bool mono; + static const _maxBytes = 512 * 1024; + const _TextViewer({required this.bytes, required this.pt, required this.mono}); + + @override + Widget build(BuildContext context) { + final bool truncated = bytes.length > _maxBytes; + final slice = truncated ? bytes.sublist(0, _maxBytes) : bytes; + final String text; + try { + text = utf8.decode(slice, allowMalformed: true); + } catch (e) { + return _ErrorView(message: e.toString(), pt: pt); + } + + return Column( + children: [ + if (truncated) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + color: pt.bg2, + child: Text('Zeige erste ${_maxBytes ~/ 1024} KB', style: TextStyle(color: pt.fgDim, fontSize: 11)), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: SelectableText( + text, + style: mono + ? TextStyle(color: pt.fg, fontFamily: 'monospace', fontSize: 13, height: 1.5) + : TextStyle(color: pt.fg, fontSize: 14, height: 1.65), + ), + ), + ), + ], + ); + } +} + +// ─── Office text extraction (DOCX / PPTX / XLSX) ──────────────────────────── + +class _OfficeTextViewer extends StatefulWidget { + final Uint8List bytes; + final String filename; + final PyramidTheme pt; + const _OfficeTextViewer({required this.bytes, required this.filename, required this.pt}); + + @override + State<_OfficeTextViewer> createState() => _OfficeTextViewerState(); +} + +class _OfficeTextViewerState extends State<_OfficeTextViewer> { + String? _text; + String? _error; + + @override + void initState() { + super.initState(); + _extract(); + } + + void _extract() { + try { + final archive = ZipDecoder().decodeBytes(widget.bytes); + final ext = widget.filename.contains('.') ? widget.filename.split('.').last.toLowerCase() : ''; + + final xmlFiles = []; + for (final f in archive.files) { + if (!f.isFile) continue; + final n = f.name.toLowerCase(); + if (ext == 'docx' && n == 'word/document.xml') { xmlFiles.add(f); break; } + if (ext == 'xlsx' && n.startsWith('xl/worksheets/') && n.endsWith('.xml')) xmlFiles.add(f); + if (ext == 'pptx' && n.startsWith('ppt/slides/slide') && n.endsWith('.xml')) xmlFiles.add(f); + // ODF formats (Writer/Impress/Calc) all use content.xml + if ((ext == 'odt' || ext == 'odp' || ext == 'ods') && n == 'content.xml') { xmlFiles.add(f); break; } + } + + final buf = StringBuffer(); + for (final f in xmlFiles) { + final xml = utf8.decode(f.content as List, allowMalformed: true); + // Strip XML tags, collapse whitespace + final stripped = xml + .replaceAll(RegExp(r'<[^>]+>'), ' ') + .replaceAll(RegExp(r'&'), '&') + .replaceAll(RegExp(r'<'), '<') + .replaceAll(RegExp(r'>'), '>') + .replaceAll(RegExp(r' '), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + buf.writeln(stripped); + buf.writeln(); + } + + _text = buf.toString().trim().isEmpty + ? '(Kein lesbarer Text gefunden)' + : buf.toString().trim(); + } catch (e) { + _error = e.toString().split('\n').first; + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + if (_error != null) return _ErrorView(message: _error!, pt: pt); + + return Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: pt.bg2, + child: Row( + children: [ + Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim), + const SizedBox(width: 8), + Text('Textvorschau (Formatierung nicht verfügbar)', style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: SelectableText( + _text ?? '', + style: TextStyle(color: pt.fg, fontSize: 14, height: 1.65), + ), + ), + ), + ], + ); + } +} + +// ─── Archive viewer ─────────────────────────────────────────────────────────── + +class _ArchiveViewer extends StatefulWidget { + final Uint8List bytes; + final String filename; + final PyramidTheme pt; + const _ArchiveViewer({required this.bytes, required this.filename, required this.pt}); + + @override + State<_ArchiveViewer> createState() => _ArchiveViewerState(); +} + +class _ArchiveViewerState extends State<_ArchiveViewer> { + List<_ArchiveEntry>? _entries; + String? _error; + + @override + void initState() { + super.initState(); + _decode(); + } + + void _decode() { + try { + final ext = widget.filename.contains('.') ? widget.filename.split('.').last.toLowerCase() : ''; + Archive archive; + if (ext == 'tar') { + archive = TarDecoder().decodeBytes(widget.bytes); + } else if (ext == 'gz' || ext == 'tgz') { + final inner = GZipDecoder().decodeBytes(widget.bytes); + archive = TarDecoder().decodeBytes(inner); + } else { + archive = ZipDecoder().decodeBytes(widget.bytes); + } + + final entries = archive.files.map((f) => _ArchiveEntry(name: f.name, size: f.size, isDir: !f.isFile)).toList() + ..sort((a, b) { + if (a.isDir != b.isDir) return a.isDir ? -1 : 1; + return a.name.compareTo(b.name); + }); + setState(() => _entries = entries); + } catch (e) { + setState(() => _error = e.toString()); + } + } + + String _fmtSize(int b) { + if (b < 1024) return '$b B'; + if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB'; + return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + @override + Widget build(BuildContext context) { + if (_error != null) return _ErrorView(message: _error!, pt: widget.pt); + if (_entries == null) return Center(child: CircularProgressIndicator(strokeWidth: 2, color: widget.pt.accent)); + + final pt = widget.pt; + final entries = _entries!; + final totalBytes = entries.fold(0, (s, e) => s + (e.isDir ? 0 : e.size)); + + return Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: pt.bg1, + child: Row( + children: [ + Icon(Icons.folder_zip_outlined, size: 15, color: pt.fgDim), + const SizedBox(width: 8), + Text('${entries.length} Einträge · ${_fmtSize(totalBytes)} unkomprimiert', style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: entries.length, + itemBuilder: (ctx, i) { + final e = entries[i]; + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + leading: Icon(e.isDir ? Icons.folder_outlined : _fileIcon(e.name), size: 18, color: e.isDir ? pt.accent : pt.fgDim), + title: Text(e.name, style: TextStyle(color: pt.fg, fontSize: 13), overflow: TextOverflow.ellipsis), + trailing: e.isDir ? null : Text(_fmtSize(e.size), style: TextStyle(color: pt.fgDim, fontSize: 11)), + ); + }, + ), + ), + ], + ); + } + + IconData _fileIcon(String name) { + final ext = name.contains('.') ? name.split('.').last.toLowerCase() : ''; + return switch (ext) { + 'dart' || 'py' || 'js' || 'ts' || 'json' || 'xml' || 'yaml' || 'toml' => Icons.code_rounded, + 'png' || 'jpg' || 'jpeg' || 'gif' || 'webp' || 'svg' => Icons.image_outlined, + 'mp4' || 'mov' || 'avi' || 'mkv' => Icons.videocam_outlined, + 'mp3' || 'ogg' || 'flac' || 'wav' => Icons.music_note_outlined, + 'pdf' => Icons.picture_as_pdf_outlined, + 'zip' || 'tar' || 'gz' || '7z' || 'rar' => Icons.folder_zip_outlined, + 'md' || 'markdown' => Icons.article_outlined, + _ => Icons.insert_drive_file_outlined, + }; + } +} + +class _ArchiveEntry { + final String name; + final int size; + final bool isDir; + const _ArchiveEntry({required this.name, required this.size, required this.isDir}); +} + +// ─── PSD/PSB thumbnail viewer ──────────────────────────────────────────────── +// Extracts the JPEG thumbnail stored in PSD Image Resource 1036. + +Uint8List? _extractPsdThumbnail(Uint8List bytes) { + try { + if (bytes.length < 34) return null; + final data = ByteData.sublistView(bytes); + // Verify '8BPS' signature + if (bytes[0] != 0x38 || bytes[1] != 0x42 || bytes[2] != 0x50 || bytes[3] != 0x53) return null; + + // PSD is big-endian throughout. + // Color Mode Data section length at offset 26. + final cmdLen = data.getUint32(26, Endian.big); + // Image Resources section starts after 26-byte header + 4-byte length field + cmdLen bytes. + final irPos = 30 + cmdLen; + if (irPos + 4 > bytes.length) return null; + final irLen = data.getUint32(irPos, Endian.big); + + int pos = irPos + 4; + final irEnd = pos + irLen; + + while (pos + 10 <= irEnd && pos + 10 <= bytes.length) { + // Signature: '8BIM' + if (bytes[pos] != 0x38 || bytes[pos+1] != 0x42 || bytes[pos+2] != 0x49 || bytes[pos+3] != 0x4D) break; + pos += 4; + + final resourceId = data.getUint16(pos, Endian.big); + pos += 2; + + // Pascal string: 1-byte length + string, total padded to even. + final nameLen = bytes[pos]; + pos += (nameLen + 2) & ~1; + + if (pos + 4 > bytes.length) break; + final dataSize = data.getUint32(pos, Endian.big); + pos += 4; + + // Resource 1036 (Photoshop 5+) or 1033 (older) = Thumbnail + if ((resourceId == 0x040C || resourceId == 0x0409) && dataSize >= 28) { + final format = data.getUint32(pos, Endian.big); + if (format == 1) { // kJpegRGB + // Use dataSize-28 for the JPEG extent (more reliable than the + // compressedSize field at pos+20, which varies across PS versions). + final jpegStart = pos + 28; + final jpegEnd = (pos + dataSize).clamp(jpegStart, bytes.length); + if (jpegEnd > jpegStart) { + final thumb = Uint8List.sublistView(bytes, jpegStart, jpegEnd); + // Validate JPEG magic bytes (FF D8) + if (thumb.length >= 2 && thumb[0] == 0xFF && thumb[1] == 0xD8) { + return thumb; + } + // Some files need a forward scan for the actual JPEG start + for (var i = 0; i < thumb.length - 1; i++) { + if (thumb[i] == 0xFF && thumb[i + 1] == 0xD8) { + return Uint8List.sublistView(thumb, i); + } + } + } + } + return null; + } + + // Advance past this resource's data (padded to even). + final advance = (dataSize + 1) & ~1; + if (advance == 0 && dataSize == 0) { pos += 0; } else { pos += advance; } + } + return null; + } catch (_) { + return null; + } +} + +class _PsdThumbViewer extends StatelessWidget { + final Uint8List bytes; + final String filename; + final PyramidTheme pt; + const _PsdThumbViewer({required this.bytes, required this.filename, required this.pt}); + + @override + Widget build(BuildContext context) { + final thumb = _extractPsdThumbnail(bytes); + if (thumb == null) { + return _ProprietaryViewer(filename: filename, bytes: bytes, pt: pt); + } + return Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + color: pt.bg2, + child: Row( + children: [ + Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim), + const SizedBox(width: 8), + Text('Eingebettete Vorschau (Vollbild nicht verfügbar)', style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + Expanded( + child: InteractiveViewer( + minScale: 0.5, + maxScale: 10, + child: Padding( + padding: const EdgeInsets.all(24), + child: Image.memory(thumb, fit: BoxFit.contain), + ), + ), + ), + ], + ); + } +} + +// ─── ZIP-preview viewer (Sketch, XD) ───────────────────────────────────────── +// These formats are ZIP archives that embed a preview PNG. + +class _ZipPreviewViewer extends StatefulWidget { + final Uint8List bytes; + final String filename; + final PyramidTheme pt; + const _ZipPreviewViewer({required this.bytes, required this.filename, required this.pt}); + + @override + State<_ZipPreviewViewer> createState() => _ZipPreviewViewerState(); +} + +class _ZipPreviewViewerState extends State<_ZipPreviewViewer> { + Uint8List? _preview; + bool _loaded = false; + bool _isImage = false; + + @override + void initState() { + super.initState(); + _extract(); + } + + void _extract() { + try { + final archive = ZipDecoder().decodeBytes(widget.bytes); + // Priority search list + const candidates = [ + 'previews/preview.png', 'preview.png', 'thumbnail.png', + 'thumbnails/thumbnail.png', 'preview.jpg', 'thumbnail.jpg', + ]; + ArchiveFile? found; + for (final path in candidates) { + found = archive.findFile(path); + if (found != null && found.isFile) break; + } + // Fallback: any file with preview/thumbnail in the name + if (found == null) { + for (final f in archive.files) { + if (!f.isFile) continue; + final n = f.name.toLowerCase(); + if ((n.contains('preview') || n.contains('thumb')) && + (n.endsWith('.png') || n.endsWith('.jpg') || n.endsWith('.jpeg'))) { + found = f; + break; + } + } + } + if (found != null) { + setState(() { + _preview = Uint8List.fromList(found!.content as List); + _isImage = true; + _loaded = true; + }); + } else { + setState(() => _loaded = true); + } + } catch (_) { + setState(() => _loaded = true); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + if (!_loaded) { + return Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)); + } + if (_isImage && _preview != null) { + return Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + color: pt.bg2, + child: Row( + children: [ + Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim), + const SizedBox(width: 8), + Text('Eingebettete Vorschau', style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + Expanded( + child: InteractiveViewer( + minScale: 0.5, + maxScale: 10, + child: Padding( + padding: const EdgeInsets.all(24), + child: Image.memory(_preview!, fit: BoxFit.contain), + ), + ), + ), + ], + ); + } + // No preview image found — fall back to archive listing + return _ArchiveViewer(bytes: widget.bytes, filename: widget.filename, pt: pt); + } +} + +// ─── Proprietary format info card ──────────────────────────────────────────── + +class _ProprietaryViewer extends StatelessWidget { + final String filename; + final Uint8List bytes; + final PyramidTheme pt; + const _ProprietaryViewer({required this.filename, required this.bytes, required this.pt}); + + String _fmtSize(int b) { + if (b < 1024) return '$b B'; + if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB'; + return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + IconData _iconFor(String ext) => switch (ext) { + 'psd' || 'psb' => Icons.photo_camera_outlined, + 'ai' || 'eps' => Icons.gesture_outlined, + 'indd' || 'inx' => Icons.menu_book_outlined, + 'afphoto' || 'afdesign' || 'afpub' => Icons.palette_outlined, + 'sketch' || 'fig' || 'xd' => Icons.design_services_outlined, + 'doc' => Icons.description_outlined, + 'ppt' => Icons.slideshow_outlined, + 'xls' => Icons.table_chart_outlined, + _ => Icons.insert_drive_file_outlined, + }; + + @override + Widget build(BuildContext context) { + final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : ''; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, height: 80, + decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rBase)), + child: Icon(_iconFor(ext), size: 40, color: pt.accent), + ), + const SizedBox(height: 16), + Text( + filename, + style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text(_fmtSize(bytes.length), style: TextStyle(color: pt.fgDim, fontSize: 13)), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(pt.rSm), + border: Border.all(color: pt.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.info_outline_rounded, size: 16, color: pt.fgDim), + const SizedBox(width: 8), + Text( + 'Format kann nicht nativ dargestellt werden', + style: TextStyle(color: pt.fgDim, fontSize: 13), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ─── Unsupported / Error ───────────────────────────────────────────────────── + +class _UnsupportedViewer extends StatelessWidget { + final PyramidTheme pt; + const _UnsupportedViewer({required this.pt}); + + @override + Widget build(BuildContext context) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.preview_outlined, size: 48, color: pt.fgDim), + const SizedBox(height: 12), + Text('Vorschau nicht verfügbar', style: TextStyle(color: pt.fgDim, fontSize: 15)), + const SizedBox(height: 4), + Text('Proprietäres Format — bitte herunterladen.', style: TextStyle(color: pt.fgMuted, fontSize: 12)), + ], + ), + ); +} + +class _ErrorView extends StatelessWidget { + final String message; + final PyramidTheme pt; + const _ErrorView({required this.message, required this.pt}); + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline_rounded, size: 32, color: pt.danger), + const SizedBox(height: 8), + Text('Fehler beim Laden', style: TextStyle(color: pt.danger, fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + Text(message, style: TextStyle(color: pt.fgMuted, fontSize: 12), textAlign: TextAlign.center), + ], + ), + ), + ); +} + +// ─── Type detection ─────────────────────────────────────────────────────────── + +enum _DocType { pdf, svg, text, code, markdown, office, archive, psdThumb, zipPreview, proprietary, unknown } + +_DocType _detectType(String filename) { + final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : ''; + return switch (ext) { + // AI/EPS files embed a full PDF when saved with PDF compatibility + 'pdf' || 'ai' || 'eps' => _DocType.pdf, + 'svg' => _DocType.svg, + 'md' || 'markdown' || 'rst' => _DocType.markdown, + 'zip' || 'tar' || 'gz' || 'tgz' || 'tar.gz' => _DocType.archive, + 'txt' || 'log' || 'csv' || 'tsv' || + 'rtf' || 'tex' || 'srt' || 'vtt' || 'nfo' => _DocType.text, + // ODF formats are ZIP+XML, same extraction approach as OOXML + 'docx' || 'xlsx' || 'pptx' || + 'odt' || 'odp' || 'ods' => _DocType.office, + 'dart' || 'py' || 'js' || 'mjs' || 'cjs' || 'ts' || 'jsx' || 'tsx' || + 'json' || 'jsonl' || 'ndjson' || 'yaml' || 'yml' || 'xml' || 'plist' || + 'html' || 'htm' || 'css' || 'scss' || 'sass' || 'less' || + 'vue' || 'svelte' || 'astro' || + 'sh' || 'bash' || 'zsh' || 'fish' || 'ps' || 'ps1' || 'bat' || 'cmd' || + 'kt' || 'kts' || 'gradle' || 'java' || 'swift' || 'go' || 'rs' || + 'cpp' || 'cc' || 'cxx' || 'c' || 'h' || 'hpp' || 'hxx' || 'm' || 'mm' || + 'cs' || 'rb' || 'php' || 'r' || 'lua' || 'pl' || 'pm' || 'scala' || + 'clj' || 'ex' || 'exs' || 'erl' || 'hs' || 'jl' || 'nim' || 'zig' || + 'toml' || 'ini' || 'conf' || 'cfg' || 'env' || 'editorconfig' || + 'properties' || 'diff' || 'patch' || 'gitignore' || 'dockerfile' || + 'sql' || 'graphql' || 'gql' || 'proto' => _DocType.code, + // PSD/PSB embed a JPEG thumbnail in Image Resource 1036 + 'psd' || 'psb' => _DocType.psdThumb, + // Sketch and XD are ZIP archives containing a preview PNG + 'sketch' || 'xd' => _DocType.zipPreview, + // Truly proprietary — no extractable preview + 'afphoto' || 'afdesign' || 'afpub' || + 'indd' || 'inx' || 'fig' || + 'doc' || 'ppt' || 'xls' => _DocType.proprietary, + _ => _DocType.unknown, + }; +} diff --git a/lib/features/chat/emoji_picker.dart b/lib/features/chat/emoji_picker.dart new file mode 100644 index 0000000..e14acbd --- /dev/null +++ b/lib/features/chat/emoji_picker.dart @@ -0,0 +1,710 @@ +import 'package:flutter/material.dart'; +import 'package:pyramid/core/theme.dart'; + +class EmojiPicker extends StatefulWidget { + final ValueChanged onEmojiSelected; + final VoidCallback onClose; + + const EmojiPicker({ + super.key, + required this.onEmojiSelected, + required this.onClose, + }); + + @override + State createState() => _EmojiPickerState(); +} + +class _EmojiPickerState extends State { + int _categoryIndex = 0; + final _searchCtrl = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final filtered = _query.isEmpty + ? _kCategories[_categoryIndex].emojis + : _kCategories + .expand((c) => c.emojis) + .where((e) => e.keywords.any((k) => k.contains(_query.toLowerCase()))) + .toList(); + + return Container( + width: 340, + height: 380, + decoration: BoxDecoration( + color: pt.bg1, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: pt.border), + boxShadow: const [ + BoxShadow(color: Color(0x50000000), blurRadius: 24, offset: Offset(0, 8)), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Column( + children: [ + // Search bar + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + child: TextField( + controller: _searchCtrl, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + hintText: 'Emoji suchen…', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), + prefixIcon: Icon(Icons.search, size: 16, color: pt.fgDim), + isDense: true, + suffixIcon: _query.isNotEmpty + ? IconButton( + icon: Icon(Icons.clear, size: 14, color: pt.fgDim), + onPressed: () { + _searchCtrl.clear(); + setState(() => _query = ''); + }, + ) + : null, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + filled: true, + fillColor: pt.bg2, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: pt.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: pt.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: pt.accent), + ), + ), + onChanged: (v) => setState(() => _query = v.trim()), + ), + ), + // Category tabs (hidden during search) + if (_query.isEmpty) + SizedBox( + height: 36, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 6), + itemCount: _kCategories.length, + itemBuilder: (ctx, i) { + final selected = i == _categoryIndex; + return GestureDetector( + onTap: () => setState(() => _categoryIndex = i), + child: Container( + width: 36, + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 2), + decoration: BoxDecoration( + color: selected ? pt.accentSoft : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: selected ? pt.accent : Colors.transparent, + ), + ), + child: Center( + child: Text( + _kCategories[i].icon, + style: const TextStyle(fontSize: 16), + ), + ), + ), + ); + }, + ), + ), + Container(height: 1, color: pt.border), + // Emoji grid + Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 8, + childAspectRatio: 1, + ), + itemCount: filtered.length, + itemBuilder: (ctx, i) { + final emoji = filtered[i]; + return _EmojiCell( + emoji: emoji.char, + tooltip: emoji.keywords.isNotEmpty ? emoji.keywords.first : '', + pt: pt, + onTap: () => widget.onEmojiSelected(emoji.char), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _EmojiCell extends StatefulWidget { + final String emoji; + final String tooltip; + final PyramidTheme pt; + final VoidCallback onTap; + + const _EmojiCell({ + required this.emoji, + required this.tooltip, + required this.pt, + required this.onTap, + }); + + @override + State<_EmojiCell> createState() => _EmojiCellState(); +} + +class _EmojiCellState extends State<_EmojiCell> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: Tooltip( + message: widget.tooltip, + waitDuration: const Duration(milliseconds: 600), + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + decoration: BoxDecoration( + color: _hovered ? widget.pt.bgHover : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text(widget.emoji, style: const TextStyle(fontSize: 20)), + ), + ), + ), + ), + ); + } +} + +// ─── Emoji data ──────────────────────────────────────────────────────────── + +class _EmojiEntry { + final String char; + final List keywords; + const _EmojiEntry(this.char, this.keywords); +} + +class _EmojiCategory { + final String icon; + final String name; + final List<_EmojiEntry> emojis; + const _EmojiCategory(this.icon, this.name, this.emojis); +} + +const _kCategories = [ + _EmojiCategory('😀', 'Smileys', _kSmileys), + _EmojiCategory('👋', 'Personen', _kPeople), + _EmojiCategory('🐶', 'Natur', _kNature), + _EmojiCategory('🍕', 'Essen', _kFood), + _EmojiCategory('⚽', 'Aktivitäten', _kActivities), + _EmojiCategory('🚗', 'Reise', _kTravel), + _EmojiCategory('💡', 'Objekte', _kObjects), + _EmojiCategory('❤️', 'Symbole', _kSymbols), +]; + +const _kSmileys = [ + _EmojiEntry('😀', ['lächeln', 'grinsen', 'happy', 'smiley']), + _EmojiEntry('😁', ['breit', 'grinsen']), + _EmojiEntry('😂', ['lachen', 'tränen', 'witzig', 'lol']), + _EmojiEntry('🤣', ['rollen', 'lachen', 'rofl']), + _EmojiEntry('😃', ['lächeln', 'glücklich']), + _EmojiEntry('😄', ['lächeln', 'augen']), + _EmojiEntry('😅', ['schwitzen', 'erleichtert']), + _EmojiEntry('😆', ['lachen', 'augen']), + _EmojiEntry('😉', ['zwinkern', 'wink']), + _EmojiEntry('😊', ['schüchtern', 'lächeln', 'rot']), + _EmojiEntry('😋', ['lecker', 'zunge']), + _EmojiEntry('😎', ['cool', 'sonnenbrille']), + _EmojiEntry('😍', ['verliebt', 'herz', 'augen']), + _EmojiEntry('🥰', ['verliebt', 'herzen']), + _EmojiEntry('😘', ['kuss', 'herz']), + _EmojiEntry('😗', ['kuss']), + _EmojiEntry('😚', ['kuss', 'augen']), + _EmojiEntry('😙', ['kuss', 'lächeln']), + _EmojiEntry('🥲', ['lächeln', 'tränen']), + _EmojiEntry('😏', ['verschmitzt', 'smirk']), + _EmojiEntry('😒', ['unzufrieden', 'unamused']), + _EmojiEntry('😞', ['enttäuscht']), + _EmojiEntry('😔', ['nachdenklich', 'traurig']), + _EmojiEntry('😟', ['besorgt']), + _EmojiEntry('😕', ['verwirrt', 'confused']), + _EmojiEntry('🙁', ['leicht', 'traurig']), + _EmojiEntry('☹️', ['traurig', 'frowning']), + _EmojiEntry('😣', ['kämpfend']), + _EmojiEntry('😖', ['verwirrt', 'confounded']), + _EmojiEntry('😫', ['erschöpft', 'tired']), + _EmojiEntry('😩', ['müde', 'weary']), + _EmojiEntry('🥺', ['bitte', 'augen']), + _EmojiEntry('😢', ['weinen', 'cry']), + _EmojiEntry('😭', ['laut', 'weinen', 'sob']), + _EmojiEntry('😤', ['wütend', 'frustriert']), + _EmojiEntry('😠', ['wütend', 'angry']), + _EmojiEntry('😡', ['sehr wütend', 'rage']), + _EmojiEntry('🤬', ['fluchen', 'wütend']), + _EmojiEntry('🤯', ['explodierend', 'schockiert']), + _EmojiEntry('😳', ['errötend', 'flushed']), + _EmojiEntry('🥵', ['heiß', 'hot']), + _EmojiEntry('🥶', ['kalt', 'cold']), + _EmojiEntry('😱', ['schreien', 'angst']), + _EmojiEntry('😨', ['ängstlich', 'fearful']), + _EmojiEntry('😰', ['schwitzen', 'angst']), + _EmojiEntry('😥', ['enttäuscht', 'erleichtert']), + _EmojiEntry('😓', ['schwitzen', 'niedergeschlagen']), + _EmojiEntry('🤗', ['umarmen', 'hug']), + _EmojiEntry('🤔', ['denken', 'thinking']), + _EmojiEntry('🫡', ['salutieren']), + _EmojiEntry('🤭', ['kichern', 'hand']), + _EmojiEntry('🫢', ['schockiert']), + _EmojiEntry('🤫', ['shush', 'flüstern']), + _EmojiEntry('🤥', ['lügen', 'pinocchio']), + _EmojiEntry('😶', ['kein mund', 'sprachlos']), + _EmojiEntry('😐', ['neutral']), + _EmojiEntry('😑', ['ausdruckslos']), + _EmojiEntry('😬', ['zähne', 'grimasse']), + _EmojiEntry('🙄', ['augen', 'genervt']), + _EmojiEntry('😯', ['überrascht', 'staunen']), + _EmojiEntry('😦', ['grimasse', 'frowning']), + _EmojiEntry('😧', ['gequält']), + _EmojiEntry('😮', ['überrascht', 'open mouth']), + _EmojiEntry('😲', ['erstaunt', 'astonished']), + _EmojiEntry('🥱', ['gähnen', 'müde']), + _EmojiEntry('🤤', ['sabbern', 'drooling']), + _EmojiEntry('😴', ['schlafen', 'zzz']), + _EmojiEntry('🤢', ['krank', 'übel']), + _EmojiEntry('🤮', ['erbrechen', 'krank']), + _EmojiEntry('🤧', ['niesen', 'krank']), + _EmojiEntry('😷', ['maske', 'krank']), + _EmojiEntry('🤒', ['fieber', 'krank']), + _EmojiEntry('🤕', ['verletzt', 'verband']), + _EmojiEntry('🤑', ['geld', 'reich']), + _EmojiEntry('🤠', ['cowboy', 'hut']), + _EmojiEntry('🥳', ['feiern', 'party']), + _EmojiEntry('🥸', ['verkleidet']), + _EmojiEntry('😎', ['cool', 'sonnenbrille']), + _EmojiEntry('🤓', ['nerd', 'brille']), + _EmojiEntry('🧐', ['monokel', 'nachdenklich']), + _EmojiEntry('😈', ['teufel', 'böse']), + _EmojiEntry('👿', ['teufel', 'wütend']), + _EmojiEntry('💀', ['tot', 'schädel']), + _EmojiEntry('☠️', ['totenkopf', 'kreuzknochgen']), + _EmojiEntry('👻', ['geist', 'halloween']), + _EmojiEntry('💩', ['häufchen', 'poop']), + _EmojiEntry('🤡', ['clown']), + _EmojiEntry('👾', ['monster', 'spiel']), + _EmojiEntry('🎃', ['halloween', 'kürbis']), +]; + +const _kPeople = [ + _EmojiEntry('👋', ['hallo', 'winken', 'wave']), + _EmojiEntry('🤚', ['hand', 'stopp']), + _EmojiEntry('✋', ['hand', 'hoch']), + _EmojiEntry('🖐️', ['hand', 'finger']), + _EmojiEntry('👌', ['ok', 'prima']), + _EmojiEntry('🤌', ['finger', 'pinch']), + _EmojiEntry('✌️', ['frieden', 'victory', 'peace']), + _EmojiEntry('🤞', ['finger', 'daumen']), + _EmojiEntry('🤟', ['liebe', 'rock']), + _EmojiEntry('🤘', ['rock', 'metal']), + _EmojiEntry('👈', ['links', 'zeigen']), + _EmojiEntry('👉', ['rechts', 'zeigen']), + _EmojiEntry('👆', ['hoch', 'zeigen']), + _EmojiEntry('👇', ['runter', 'zeigen']), + _EmojiEntry('☝️', ['eins', 'oben']), + _EmojiEntry('👍', ['daumen', 'hoch', 'gut', 'like']), + _EmojiEntry('👎', ['daumen', 'runter', 'schlecht', 'dislike']), + _EmojiEntry('✊', ['faust', 'punch']), + _EmojiEntry('👊', ['faust', 'schlag']), + _EmojiEntry('🤛', ['faust', 'links']), + _EmojiEntry('🤜', ['faust', 'rechts']), + _EmojiEntry('👏', ['klatschen', 'applaus']), + _EmojiEntry('🙌', ['feiern', 'hände']), + _EmojiEntry('🤝', ['handschlag', 'vereinbarung']), + _EmojiEntry('🙏', ['bitte', 'danke', 'beten']), + _EmojiEntry('✍️', ['schreiben', 'stift']), + _EmojiEntry('💪', ['muskel', 'stark']), + _EmojiEntry('🦵', ['bein']), + _EmojiEntry('🦶', ['fuß']), + _EmojiEntry('👂', ['ohr', 'hören']), + _EmojiEntry('👃', ['nase', 'riechen']), + _EmojiEntry('🧠', ['gehirn', 'denken']), + _EmojiEntry('🦷', ['zahn']), + _EmojiEntry('👀', ['augen', 'schauen']), + _EmojiEntry('👁️', ['auge']), + _EmojiEntry('👅', ['zunge']), + _EmojiEntry('👄', ['lippen', 'kuss']), + _EmojiEntry('💋', ['kuss', 'lippen']), + _EmojiEntry('👶', ['baby']), + _EmojiEntry('🧒', ['kind']), + _EmojiEntry('👦', ['junge']), + _EmojiEntry('👧', ['mädchen']), + _EmojiEntry('🧑', ['person']), + _EmojiEntry('👱', ['blond']), + _EmojiEntry('👩', ['frau']), + _EmojiEntry('👨', ['mann']), + _EmojiEntry('🧓', ['älter']), + _EmojiEntry('👴', ['alter mann']), + _EmojiEntry('👵', ['alte frau']), + _EmojiEntry('🧑‍💻', ['programmierer', 'developer']), + _EmojiEntry('👨‍💻', ['mann', 'programmierer']), + _EmojiEntry('👩‍💻', ['frau', 'programmierin']), +]; + +const _kNature = [ + _EmojiEntry('🐶', ['hund', 'dog']), + _EmojiEntry('🐱', ['katze', 'cat']), + _EmojiEntry('🐭', ['maus', 'mouse']), + _EmojiEntry('🐹', ['hamster']), + _EmojiEntry('🐰', ['hase', 'rabbit']), + _EmojiEntry('🦊', ['fuchs', 'fox']), + _EmojiEntry('🐻', ['bär', 'bear']), + _EmojiEntry('🐼', ['panda']), + _EmojiEntry('🐨', ['koala']), + _EmojiEntry('🐯', ['tiger']), + _EmojiEntry('🦁', ['löwe', 'lion']), + _EmojiEntry('🐮', ['kuh', 'cow']), + _EmojiEntry('🐷', ['schwein', 'pig']), + _EmojiEntry('🐸', ['frosch', 'frog']), + _EmojiEntry('🐵', ['affe', 'monkey']), + _EmojiEntry('🐔', ['huhn', 'chicken']), + _EmojiEntry('🐧', ['pinguin', 'penguin']), + _EmojiEntry('🐦', ['vogel', 'bird']), + _EmojiEntry('🦆', ['ente', 'duck']), + _EmojiEntry('🦅', ['adler', 'eagle']), + _EmojiEntry('🦉', ['eule', 'owl']), + _EmojiEntry('🦇', ['fledermaus', 'bat']), + _EmojiEntry('🐺', ['wolf']), + _EmojiEntry('🐗', ['wildschwein', 'boar']), + _EmojiEntry('🐴', ['pferd', 'horse']), + _EmojiEntry('🦄', ['einhorn', 'unicorn']), + _EmojiEntry('🐝', ['biene', 'bee']), + _EmojiEntry('🐛', ['raupe', 'caterpillar']), + _EmojiEntry('🦋', ['schmetterling', 'butterfly']), + _EmojiEntry('🐌', ['schnecke', 'snail']), + _EmojiEntry('🐞', ['marienkäfer', 'ladybug']), + _EmojiEntry('🐜', ['ameise', 'ant']), + _EmojiEntry('🌸', ['kirschblüte', 'cherry blossom']), + _EmojiEntry('🌺', ['hibiskus']), + _EmojiEntry('🌻', ['sonnenblume', 'sunflower']), + _EmojiEntry('🌹', ['rose']), + _EmojiEntry('🌷', ['tulpe', 'tulip']), + _EmojiEntry('🌼', ['blume', 'flower']), + _EmojiEntry('🌿', ['pflanze', 'plant']), + _EmojiEntry('☘️', ['kleeblatt', 'shamrock']), + _EmojiEntry('🍀', ['vierblättriges', 'glück', 'luck']), + _EmojiEntry('🌲', ['baum', 'tree']), + _EmojiEntry('🌳', ['baum', 'deciduous']), + _EmojiEntry('🌴', ['palme', 'palm']), + _EmojiEntry('🌵', ['kaktus', 'cactus']), + _EmojiEntry('🌾', ['gras', 'getreide']), + _EmojiEntry('🍄', ['pilz', 'mushroom']), + _EmojiEntry('🌊', ['welle', 'wave', 'ozean']), + _EmojiEntry('⛅', ['wolken', 'sonne']), + _EmojiEntry('🌈', ['regenbogen', 'rainbow']), + _EmojiEntry('❄️', ['schnee', 'schneflocke', 'cold']), + _EmojiEntry('⭐', ['stern', 'star']), + _EmojiEntry('🌟', ['stern', 'glitzern']), + _EmojiEntry('✨', ['funken', 'glitzern', 'sparkle']), + _EmojiEntry('🔥', ['feuer', 'fire', 'heiß']), + _EmojiEntry('🌙', ['mond', 'moon']), + _EmojiEntry('☀️', ['sonne', 'sun']), +]; + +const _kFood = [ + _EmojiEntry('🍎', ['apfel', 'apple']), + _EmojiEntry('🍊', ['orange', 'mandarine']), + _EmojiEntry('🍋', ['zitrone', 'lemon']), + _EmojiEntry('🍇', ['trauben', 'grapes']), + _EmojiEntry('🍓', ['erdbeere', 'strawberry']), + _EmojiEntry('🍒', ['kirsche', 'cherry']), + _EmojiEntry('🍑', ['pfirsich', 'peach']), + _EmojiEntry('🥭', ['mango']), + _EmojiEntry('🍍', ['ananas', 'pineapple']), + _EmojiEntry('🥥', ['kokosnuss', 'coconut']), + _EmojiEntry('🍅', ['tomate', 'tomato']), + _EmojiEntry('🫐', ['blaubeere', 'blueberry']), + _EmojiEntry('🍆', ['aubergine', 'eggplant']), + _EmojiEntry('🥑', ['avocado']), + _EmojiEntry('🫑', ['paprika']), + _EmojiEntry('🌽', ['mais', 'corn']), + _EmojiEntry('🥕', ['karotte', 'carrot']), + _EmojiEntry('🧄', ['knoblauch', 'garlic']), + _EmojiEntry('🧅', ['zwiebel', 'onion']), + _EmojiEntry('🥔', ['kartoffel', 'potato']), + _EmojiEntry('🍞', ['brot', 'bread']), + _EmojiEntry('🥐', ['croissant']), + _EmojiEntry('🧀', ['käse', 'cheese']), + _EmojiEntry('🥚', ['ei', 'egg']), + _EmojiEntry('🍳', ['bratpfanne', 'kochen']), + _EmojiEntry('🥓', ['speck', 'bacon']), + _EmojiEntry('🍗', ['hühnchen', 'chicken']), + _EmojiEntry('🍖', ['fleisch', 'knochen']), + _EmojiEntry('🌭', ['hotdog']), + _EmojiEntry('🍔', ['burger', 'hamburger']), + _EmojiEntry('🍟', ['pommes', 'fries']), + _EmojiEntry('🍕', ['pizza']), + _EmojiEntry('🥪', ['sandwich']), + _EmojiEntry('🌮', ['taco']), + _EmojiEntry('🌯', ['wrap']), + _EmojiEntry('🥗', ['salat', 'salad']), + _EmojiEntry('🍜', ['nudeln', 'noodles', 'ramen']), + _EmojiEntry('🍣', ['sushi']), + _EmojiEntry('🍦', ['eis', 'softeis']), + _EmojiEntry('🎂', ['torte', 'geburtstag']), + _EmojiEntry('🍰', ['kuchen', 'cake']), + _EmojiEntry('🧁', ['muffin', 'cupcake']), + _EmojiEntry('🍩', ['donut']), + _EmojiEntry('🍪', ['keks', 'cookie']), + _EmojiEntry('🍫', ['schokolade', 'chocolate']), + _EmojiEntry('🍬', ['bonbon', 'candy']), + _EmojiEntry('☕', ['kaffee', 'coffee']), + _EmojiEntry('🍵', ['tee', 'tea']), + _EmojiEntry('🧋', ['bubble tea']), + _EmojiEntry('🥤', ['getränk', 'cup']), + _EmojiEntry('🍺', ['bier', 'beer']), + _EmojiEntry('🍻', ['bier', 'prost', 'cheers']), + _EmojiEntry('🥂', ['sekt', 'cheers', 'toast']), + _EmojiEntry('🍷', ['wein', 'wine']), + _EmojiEntry('🥃', ['whiskey']), +]; + +const _kActivities = [ + _EmojiEntry('⚽', ['fußball', 'soccer']), + _EmojiEntry('🏀', ['basketball']), + _EmojiEntry('🏈', ['american football']), + _EmojiEntry('⚾', ['baseball']), + _EmojiEntry('🎾', ['tennis']), + _EmojiEntry('🏐', ['volleyball']), + _EmojiEntry('🏉', ['rugby']), + _EmojiEntry('🥏', ['frisbee']), + _EmojiEntry('🎱', ['billard', 'pool']), + _EmojiEntry('🏓', ['tischtennis', 'ping pong']), + _EmojiEntry('🏸', ['badminton']), + _EmojiEntry('🥊', ['boxen', 'boxing']), + _EmojiEntry('🥋', ['kampfsport', 'martial arts']), + _EmojiEntry('🥅', ['tor', 'goal']), + _EmojiEntry('⛳', ['golf']), + _EmojiEntry('🎿', ['ski']), + _EmojiEntry('🛷', ['schlitten', 'sled']), + _EmojiEntry('🏆', ['pokal', 'trophy', 'gewonnen']), + _EmojiEntry('🥇', ['gold', 'erster']), + _EmojiEntry('🥈', ['silber', 'zweiter']), + _EmojiEntry('🥉', ['bronze', 'dritter']), + _EmojiEntry('🎮', ['spiel', 'gaming', 'controller']), + _EmojiEntry('🕹️', ['joystick', 'spiel']), + _EmojiEntry('🎲', ['würfel', 'dice']), + _EmojiEntry('🧩', ['puzzle']), + _EmojiEntry('🎯', ['ziel', 'dartscheibe']), + _EmojiEntry('🎳', ['bowling']), + _EmojiEntry('🎪', ['zirkus', 'circus']), + _EmojiEntry('🎨', ['kunst', 'malen', 'art']), + _EmojiEntry('🎭', ['theater', 'drama']), + _EmojiEntry('🎬', ['film', 'klappe', 'movie']), + _EmojiEntry('🎵', ['musik', 'note']), + _EmojiEntry('🎶', ['musik', 'noten']), + _EmojiEntry('🎸', ['gitarre', 'guitar']), + _EmojiEntry('🎹', ['klavier', 'piano']), + _EmojiEntry('🥁', ['schlagzeug', 'drum']), + _EmojiEntry('🎷', ['saxophon']), + _EmojiEntry('🎺', ['trompete', 'trumpet']), + _EmojiEntry('🎻', ['geige', 'violin']), + _EmojiEntry('🎤', ['mikrofon', 'microphone']), +]; + +const _kTravel = [ + _EmojiEntry('🚗', ['auto', 'car']), + _EmojiEntry('🚕', ['taxi']), + _EmojiEntry('🚙', ['suv', 'auto']), + _EmojiEntry('🚌', ['bus']), + _EmojiEntry('🚎', ['trolleybus']), + _EmojiEntry('🏎️', ['rennauto', 'racecar']), + _EmojiEntry('🚓', ['polizei', 'police']), + _EmojiEntry('🚑', ['krankenwagen', 'ambulance']), + _EmojiEntry('🚒', ['feuerwehr', 'fire truck']), + _EmojiEntry('🚐', ['minibus', 'van']), + _EmojiEntry('🛻', ['pickup']), + _EmojiEntry('🚚', ['lieferwagen', 'truck']), + _EmojiEntry('🚛', ['lkw', 'truck']), + _EmojiEntry('🏍️', ['motorrad', 'motorcycle']), + _EmojiEntry('🚲', ['fahrrad', 'bicycle', 'bike']), + _EmojiEntry('🛵', ['roller', 'scooter']), + _EmojiEntry('✈️', ['flugzeug', 'plane', 'fliegen']), + _EmojiEntry('🚀', ['rakete', 'rocket', 'space']), + _EmojiEntry('🛸', ['ufo']), + _EmojiEntry('🚁', ['hubschrauber', 'helicopter']), + _EmojiEntry('⛵', ['segelboot', 'sailboat']), + _EmojiEntry('🚢', ['schiff', 'ship']), + _EmojiEntry('🚂', ['zug', 'train']), + _EmojiEntry('🚄', ['hochgeschwindigkeitszug', 'bullet train']), + _EmojiEntry('🏠', ['haus', 'home']), + _EmojiEntry('🏡', ['haus', 'garten']), + _EmojiEntry('🏢', ['büro', 'office']), + _EmojiEntry('🏰', ['schloss', 'castle']), + _EmojiEntry('🗼', ['turm', 'eiffelturm']), + _EmojiEntry('🗽', ['freiheitsstatue']), + _EmojiEntry('🌍', ['erde', 'europa', 'africa']), + _EmojiEntry('🌎', ['erde', 'americas']), + _EmojiEntry('🌏', ['erde', 'asia']), + _EmojiEntry('🗺️', ['karte', 'map']), + _EmojiEntry('🧭', ['kompass', 'compass']), + _EmojiEntry('⛺', ['zelt', 'camping']), + _EmojiEntry('🏖️', ['strand', 'beach']), + _EmojiEntry('🏔️', ['berg', 'mountain']), + _EmojiEntry('🌋', ['vulkan', 'volcano']), +]; + +const _kObjects = [ + _EmojiEntry('💡', ['idee', 'licht', 'light', 'lamp']), + _EmojiEntry('🔦', ['taschenlampe', 'flashlight']), + _EmojiEntry('💻', ['laptop', 'computer']), + _EmojiEntry('🖥️', ['monitor', 'desktop']), + _EmojiEntry('🖨️', ['drucker', 'printer']), + _EmojiEntry('⌨️', ['tastatur', 'keyboard']), + _EmojiEntry('🖱️', ['maus', 'mouse']), + _EmojiEntry('📱', ['handy', 'phone', 'smartphone']), + _EmojiEntry('☎️', ['telefon', 'phone']), + _EmojiEntry('📞', ['telefon', 'anruf', 'call']), + _EmojiEntry('📷', ['kamera', 'camera']), + _EmojiEntry('📸', ['foto', 'kamera', 'selfie']), + _EmojiEntry('📹', ['video', 'kamera']), + _EmojiEntry('🎥', ['film', 'kamera', 'movie']), + _EmojiEntry('📺', ['tv', 'fernseher', 'television']), + _EmojiEntry('📻', ['radio']), + _EmojiEntry('🎙️', ['mikrofon', 'microphone']), + _EmojiEntry('📡', ['satellit', 'satellite']), + _EmojiEntry('🔋', ['batterie', 'battery']), + _EmojiEntry('🔌', ['stecker', 'plug']), + _EmojiEntry('💾', ['diskette', 'speichern', 'save']), + _EmojiEntry('💿', ['cd', 'disk']), + _EmojiEntry('📀', ['dvd', 'disc']), + _EmojiEntry('📁', ['ordner', 'folder']), + _EmojiEntry('📂', ['ordner', 'offen']), + _EmojiEntry('📄', ['datei', 'dokument', 'file']), + _EmojiEntry('📃', ['seite', 'dokument']), + _EmojiEntry('📋', ['zwischenablage', 'clipboard']), + _EmojiEntry('📊', ['grafik', 'diagramm', 'chart']), + _EmojiEntry('📈', ['aufwärts', 'wachstum', 'chart']), + _EmojiEntry('📉', ['abwärts', 'rückgang', 'chart']), + _EmojiEntry('📌', ['pin', 'nadel']), + _EmojiEntry('📍', ['ort', 'pin', 'location']), + _EmojiEntry('✏️', ['stift', 'pen']), + _EmojiEntry('✒️', ['füllfeder', 'pen']), + _EmojiEntry('🖊️', ['kugelschreiber', 'pen']), + _EmojiEntry('📝', ['notiz', 'memo', 'schreiben']), + _EmojiEntry('📚', ['bücher', 'books']), + _EmojiEntry('📖', ['buch', 'lesen']), + _EmojiEntry('🔑', ['schlüssel', 'key']), + _EmojiEntry('🔒', ['schloss', 'lock']), + _EmojiEntry('🔓', ['offen', 'unlock']), + _EmojiEntry('🔨', ['hammer']), + _EmojiEntry('🪛', ['schraubenzieher']), + _EmojiEntry('⚙️', ['zahnrad', 'einstellungen', 'settings']), + _EmojiEntry('🧲', ['magnet']), + _EmojiEntry('🔭', ['teleskop', 'telescope']), + _EmojiEntry('🔬', ['mikroskop', 'microscope']), + _EmojiEntry('💊', ['pille', 'medikament']), + _EmojiEntry('🩺', ['stethoskop', 'arzt']), + _EmojiEntry('🧪', ['reagenzglas', 'experiment']), + _EmojiEntry('💰', ['geld', 'tasche', 'money']), + _EmojiEntry('💳', ['karte', 'kreditkarte']), + _EmojiEntry('🎁', ['geschenk', 'present']), + _EmojiEntry('🎀', ['schleife', 'ribbon']), + _EmojiEntry('🧨', ['feuerwerk', 'cracker']), + _EmojiEntry('🎉', ['feier', 'party', 'celebration']), + _EmojiEntry('🎊', ['konfetti', 'party']), + _EmojiEntry('🏮', ['laterne', 'lantern']), + _EmojiEntry('⌚', ['uhr', 'watch', 'zeit']), + _EmojiEntry('📅', ['kalender', 'calendar']), + _EmojiEntry('⏰', ['wecker', 'alarm']), + _EmojiEntry('⏳', ['sanduhr', 'hourglass']), +]; + +const _kSymbols = [ + _EmojiEntry('❤️', ['herz', 'liebe', 'love', 'heart']), + _EmojiEntry('🧡', ['orange', 'herz']), + _EmojiEntry('💛', ['gelb', 'herz']), + _EmojiEntry('💚', ['grün', 'herz']), + _EmojiEntry('💙', ['blau', 'herz']), + _EmojiEntry('💜', ['lila', 'herz']), + _EmojiEntry('🖤', ['schwarz', 'herz']), + _EmojiEntry('🤍', ['weiß', 'herz']), + _EmojiEntry('🤎', ['braun', 'herz']), + _EmojiEntry('💔', ['gebrochenes herz', 'broken heart']), + _EmojiEntry('❣️', ['herz', 'ausrufezeichen']), + _EmojiEntry('💕', ['zwei herzen', 'love']), + _EmojiEntry('💞', ['herzen', 'drehend']), + _EmojiEntry('💓', ['herz', 'schlagen']), + _EmojiEntry('💗', ['herz', 'wachsend']), + _EmojiEntry('💖', ['herz', 'glitzer']), + _EmojiEntry('💝', ['herz', 'schleife']), + _EmojiEntry('💘', ['herz', 'pfeil', 'cupid']), + _EmojiEntry('💟', ['herz', 'dekoration']), + _EmojiEntry('☮️', ['frieden', 'peace']), + _EmojiEntry('✝️', ['kreuz', 'christian']), + _EmojiEntry('☯️', ['yin yang']), + _EmojiEntry('🔴', ['rot', 'kreis', 'red']), + _EmojiEntry('🟠', ['orange', 'kreis']), + _EmojiEntry('🟡', ['gelb', 'kreis']), + _EmojiEntry('🟢', ['grün', 'kreis']), + _EmojiEntry('🔵', ['blau', 'kreis']), + _EmojiEntry('🟣', ['lila', 'kreis']), + _EmojiEntry('⚫', ['schwarz', 'kreis']), + _EmojiEntry('⚪', ['weiß', 'kreis']), + _EmojiEntry('🟤', ['braun', 'kreis']), + _EmojiEntry('🔺', ['rot', 'dreieck']), + _EmojiEntry('🔻', ['rot', 'dreieck', 'runter']), + _EmojiEntry('♾️', ['unendlich', 'infinity']), + _EmojiEntry('✅', ['haken', 'check', 'ok', 'ja']), + _EmojiEntry('❌', ['x', 'nein', 'falsch', 'close']), + _EmojiEntry('❎', ['x', 'kreuz', 'nein']), + _EmojiEntry('⭕', ['kreis', 'richtig']), + _EmojiEntry('🚫', ['verboten', 'nein']), + _EmojiEntry('⚠️', ['warnung', 'warning']), + _EmojiEntry('🔞', ['verboten', '18+']), + _EmojiEntry('❗', ['ausrufezeichen', 'wichtig']), + _EmojiEntry('❓', ['fragezeichen', 'frage']), + _EmojiEntry('💯', ['100', 'perfekt', 'perfect']), + _EmojiEntry('🆗', ['ok', 'schaltfläche']), + _EmojiEntry('🆕', ['neu', 'new']), + _EmojiEntry('🆙', ['up']), + _EmojiEntry('🔝', ['top', 'oben']), + _EmojiEntry('🔛', ['an']), + _EmojiEntry('🔜', ['bald', 'soon']), + _EmojiEntry('🔚', ['ende', 'end']), + _EmojiEntry('♻️', ['recycling', 'wiederverwertung']), + _EmojiEntry('💲', ['dollar', 'geld']), + _EmojiEntry('©️', ['copyright']), + _EmojiEntry('®️', ['eingetragen', 'trademark']), + _EmojiEntry('™️', ['markenzeichen', 'trademark']), + _EmojiEntry('🔔', ['glocke', 'bell', 'benachrichtigung']), + _EmojiEntry('🔕', ['stille', 'no bell']), + _EmojiEntry('🎵', ['musik', 'note']), + _EmojiEntry('🎶', ['musik', 'noten']), + _EmojiEntry('#️⃣', ['raute', 'hash', 'hashtag']), + _EmojiEntry('*️⃣', ['stern', 'asterisk']), + _EmojiEntry('0️⃣', ['null', 'zero']), + _EmojiEntry('1️⃣', ['eins', 'one']), + _EmojiEntry('2️⃣', ['zwei', 'two']), + _EmojiEntry('3️⃣', ['drei', 'three']), +]; diff --git a/lib/features/chat/gif_sticker_picker.dart b/lib/features/chat/gif_sticker_picker.dart new file mode 100644 index 0000000..eb18cb3 --- /dev/null +++ b/lib/features/chat/gif_sticker_picker.dart @@ -0,0 +1,696 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/utils/gif_favorite_service.dart'; + +const _giphyApiKey = 'QtEyHWSKVIZJersKNBbJGYIgOhwawjkk'; +const _stickerServerUrl = 'https://stickers.steggi-matrix.work'; + +class GifStickerPicker extends StatefulWidget { + final Room room; + final VoidCallback onClose; + + const GifStickerPicker({ + super.key, + required this.room, + required this.onClose, + }); + + @override + State createState() => _GifStickerPickerState(); +} + +class _GifStickerPickerState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + final _searchController = TextEditingController(); + final _customUrlController = TextEditingController(); + final _gifScrollController = ScrollController(); + final _packSelectorScroll = ScrollController(); + + List _gifs = []; + bool _gifLoading = false; + int _gifOffset = 0; + int _gifTotal = 0; + String _gifQuery = ''; + int? _hoveredGifIndex; + int? _hoveredFavIndex; + + static const _kFavsPack = '__favs__'; + static const _kMyPack = '__my__'; + + List _packs = []; + final Map _packData = {}; + String? _selectedPack; + bool _stickerLoading = false; + bool _packLoading = false; + PyramidTheme? _pt; + + List get _allPackTabs { + final tabs = []; + if (GifFavoriteService.stickerFavoritesItems.isNotEmpty) tabs.add(_kFavsPack); + if (GifFavoriteService.userStickerItems.isNotEmpty) tabs.add(_kMyPack); + tabs.addAll(_packs); + return tabs; + } + + void _selectDefaultTab() { + final tabs = _allPackTabs; + if (tabs.isNotEmpty && (_selectedPack == null || !tabs.contains(_selectedPack))) { + setState(() => _selectedPack = tabs.first); + } + } + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _gifScrollController.addListener(_onGifScroll); + _loadTrendingGifs(); + GifFavoriteService.load().then((_) => setState(() {})); + GifFavoriteService.loadStickers().then((_) { + _selectDefaultTab(); + setState(() {}); + }); + _loadStickerPacks(); + } + + @override + void dispose() { + _tabController.dispose(); + _searchController.dispose(); + _customUrlController.dispose(); + _gifScrollController.dispose(); + _packSelectorScroll.dispose(); + super.dispose(); + } + + void _onGifScroll() { + if (_gifScrollController.position.pixels >= + _gifScrollController.position.maxScrollExtent - 300) { + if (!_gifLoading && _gifOffset < _gifTotal) _loadMoreGifs(); + } + } + + Future _loadTrendingGifs() async { + setState(() { _gifLoading = true; _gifs = []; _gifOffset = 0; _gifQuery = ''; }); + try { + final res = await http.get(Uri.parse( + 'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=24&offset=0', + )); + final data = jsonDecode(res.body); + setState(() { + _gifs = data['data'] ?? []; + _gifTotal = data['pagination']?['total_count'] ?? 0; + _gifOffset = _gifs.length; + _gifLoading = false; + }); + } catch (_) { + setState(() => _gifLoading = false); + } + } + + Future _searchGifs(String query) async { + if (query.isEmpty) { _loadTrendingGifs(); return; } + setState(() { _gifLoading = true; _gifs = []; _gifOffset = 0; _gifQuery = query; }); + try { + final res = await http.get(Uri.parse( + 'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(query)}&limit=24&offset=0', + )); + final data = jsonDecode(res.body); + setState(() { + _gifs = data['data'] ?? []; + _gifTotal = data['pagination']?['total_count'] ?? 0; + _gifOffset = _gifs.length; + _gifLoading = false; + }); + } catch (_) { + setState(() => _gifLoading = false); + } + } + + Future _loadMoreGifs() async { + if (_gifLoading) return; + setState(() => _gifLoading = true); + try { + final url = _gifQuery.isEmpty + ? 'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=24&offset=$_gifOffset' + : 'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(_gifQuery)}&limit=24&offset=$_gifOffset'; + final res = await http.get(Uri.parse(url)); + final data = jsonDecode(res.body); + setState(() { + _gifs.addAll(data['data'] ?? []); + _gifOffset = _gifs.length; + _gifLoading = false; + }); + } catch (_) { + setState(() => _gifLoading = false); + } + } + + Future _toggleGifFav(String url, String previewUrl, String title) async { + await GifFavoriteService.toggle(url, previewUrl, title); + setState(() {}); + } + + Future _loadStickerPacks() async { + setState(() => _stickerLoading = true); + try { + final userId = widget.room.client.userID ?? ''; + final res = await http.get(Uri.parse( + '$_stickerServerUrl/packs/index.json?userId=${Uri.encodeComponent(userId)}', + )); + final data = jsonDecode(res.body); + final packs = List.from(data['packs'] ?? []) + .where((p) => !p.startsWith('__')) + .toList(); + setState(() { _packs = packs; _stickerLoading = false; }); + _selectDefaultTab(); + if (_selectedPack != null && _selectedPack != _kFavsPack && _selectedPack != _kMyPack) { + await _loadPack(_selectedPack!); + } + } catch (_) { + setState(() => _stickerLoading = false); + } + } + + Future _loadPack(String packId) async { + if (_packData.containsKey(packId)) return; + setState(() => _packLoading = true); + try { + final res = await http.get(Uri.parse('$_stickerServerUrl/packs/$packId/pack.json')); + setState(() { _packData[packId] = jsonDecode(res.body); _packLoading = false; }); + } catch (_) { + setState(() => _packLoading = false); + } + } + + Future _sendGif(String url, String title) async { + widget.onClose(); + await widget.room.sendEvent({ + 'msgtype': 'm.image', + 'body': title.isNotEmpty ? title : 'GIF', + 'url': url, + 'info': {'mimetype': 'image/gif'}, + }); + } + + Future _sendSticker(String mxcUrl, String body, Map info) async { + widget.onClose(); + await widget.room.sendEvent( + {'body': body, 'url': mxcUrl, 'info': info}, + type: EventTypes.Sticker, + ); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + _pt = pt; + return Container( + width: 380, + height: 460, + decoration: BoxDecoration( + color: pt.bg1, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: pt.border), + boxShadow: const [ + BoxShadow(color: Color(0x50000000), blurRadius: 24, offset: Offset(0, 8)) + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Column( + children: [ + // Header with tabs + Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: pt.border)), + ), + child: TabBar( + controller: _tabController, + indicatorColor: pt.accent, + labelColor: pt.accent, + unselectedLabelColor: pt.fgMuted, + tabs: const [ + Tab(icon: Icon(Icons.gif_box_outlined, size: 20)), + Tab(icon: Icon(Icons.sticky_note_2_outlined, size: 20)), + ], + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [_buildGifTab(), _buildStickerTab()], + ), + ), + ], + ), + ), + ); + } + + // ── GIF Tab ────────────────────────────────────────────────────────────── + + Widget _buildGifTab() { + final favorites = GifFavoriteService.cache; + final isSearching = _gifQuery.isNotEmpty; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + child: TextField( + controller: _searchController, + style: TextStyle(color: _pt!.fg, fontSize: 13), + decoration: InputDecoration( + hintText: 'GIFs suchen…', + hintStyle: TextStyle(color: _pt!.fgDim, fontSize: 13), + prefixIcon: Icon(Icons.search, size: 16, color: _pt!.fgDim), + isDense: true, + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: Icon(Icons.clear, size: 16, color: _pt!.fgDim), + onPressed: () { + _searchController.clear(); + setState(() {}); + _loadTrendingGifs(); + }, + ) + : null, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + filled: true, + fillColor: _pt!.bg2, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: _pt!.border)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: _pt!.border)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: _pt!.accent)), + ), + onChanged: (v) => setState(() {}), + onSubmitted: _searchGifs, + ), + ), + Expanded( + child: _gifLoading && _gifs.isEmpty + ? Center(child: CircularProgressIndicator(color: _pt!.accent)) + : CustomScrollView( + controller: _gifScrollController, + slivers: [ + if (!isSearching && favorites.isNotEmpty) ...[ + SliverToBoxAdapter(child: _SectionLabel('❤ Favoriten')), + _gifGrid( + count: favorites.length, + builder: (i) { + final fav = favorites[i]; + final url = fav['url'] ?? ''; + final preview = fav['preview_url'] ?? url; + final title = fav['title'] ?? 'GIF'; + return _GifCell( + imageUrl: preview, + isFavorite: true, + isHovered: _hoveredFavIndex == i, + onHoverChanged: (h) => setState(() => _hoveredFavIndex = h ? i : null), + onTap: () => _sendGif(url, title), + onFavToggle: () => _toggleGifFav(url, preview, title), + ); + }, + ), + SliverToBoxAdapter(child: _SectionLabel('Trending')), + ], + _gifGrid( + count: _gifs.length, + builder: (i) { + final gif = _gifs[i]; + final preview = gif['images']?['fixed_height_small']?['url'] ?? gif['images']?['fixed_height']?['url'] ?? ''; + final full = gif['images']?['fixed_height']?['url'] ?? ''; + final title = gif['title'] ?? 'GIF'; + final fav = GifFavoriteService.isFavorite(full); + return _GifCell( + imageUrl: preview, + isFavorite: fav, + isHovered: _hoveredGifIndex == i, + onHoverChanged: (h) => setState(() => _hoveredGifIndex = h ? i : null), + onTap: () => _sendGif(full, title), + onFavToggle: () => _toggleGifFav(full, preview, title), + ); + }, + ), + if (_gifLoading) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator( + strokeWidth: 2, color: _pt!.accent)), + ), + ), + ], + ), + ), + ], + ); + } + + SliverGrid _gifGrid({required int count, required Widget Function(int) builder}) => + SliverGrid( + delegate: SliverChildBuilderDelegate((ctx, i) => builder(i), childCount: count), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, crossAxisSpacing: 2, mainAxisSpacing: 2, childAspectRatio: 1, + ), + ); + + // ── Sticker Tab ────────────────────────────────────────────────────────── + + Widget _buildStickerTab() { + final allTabs = _allPackTabs; + if (_stickerLoading) { + return Center(child: CircularProgressIndicator(color: _pt!.accent)); + } + if (allTabs.isEmpty) { + return Center(child: Text('Keine Sticker verfügbar', style: TextStyle(color: _pt!.fgMuted))); + } + final selected = (allTabs.contains(_selectedPack) ? _selectedPack : allTabs.first)!; + + return Column( + children: [ + SizedBox( + height: 38, + child: ListView.builder( + controller: _packSelectorScroll, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + itemCount: allTabs.length, + itemBuilder: (context, index) { + final tab = allTabs[index]; + final isSelected = tab == selected; + String label = tab == _kFavsPack ? '⭐ Favoriten' : tab == _kMyPack ? '🎨 Meine' : (_packData[tab]?['title'] as String? ?? tab); + return Padding( + padding: const EdgeInsets.only(right: 6), + child: GestureDetector( + onTap: () async { + setState(() => _selectedPack = tab); + if (tab != _kFavsPack && tab != _kMyPack) await _loadPack(tab); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + color: isSelected ? _pt!.accentSoft : Colors.transparent, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isSelected ? _pt!.accent : _pt!.border, + ), + ), + child: Text(label, + style: TextStyle( + fontSize: 11, + color: isSelected ? _pt!.accent : _pt!.fgMuted, + )), + ), + ), + ); + }, + ), + ), + Container(height: 1, color: _pt!.border), + Expanded( + child: selected == _kFavsPack + ? _buildStickerGrid(GifFavoriteService.stickerFavoritesItems) + : selected == _kMyPack + ? _buildStickerGrid(GifFavoriteService.userStickerItems, isMyStickers: true) + : _buildPackGrid(selected), + ), + ], + ); + } + + Widget _buildStickerGrid(List> items, {bool isMyStickers = false}) { + if (items.isEmpty) { + return Center(child: Text('Keine Sticker', style: TextStyle(color: _pt!.fgMuted))); + } + return GridView.builder( + padding: const EdgeInsets.all(4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, crossAxisSpacing: 3, mainAxisSpacing: 3, + ), + itemCount: items.length, + itemBuilder: (ctx, i) { + final s = items[i]; + final piUrl = s['url'] as String? ?? ''; + final sendMxc = (s['mxc'] as String?)?.isNotEmpty == true + ? s['mxc'] as String + : (s['mxc_source'] as String? ?? ''); + final mxcSource = s['mxc_source'] as String? ?? ''; + final title = s['title'] as String? ?? 'Sticker'; + final info = {'mimetype': s['mimetype'] ?? 'image/webp'}; + final isUserSticker = s['user_sticker'] == true; + + return _StickerCell( + piUrl: piUrl, + isFavorited: s['favorited'] == true, + showHeart: isMyStickers && isUserSticker, + onTap: () => _sendSticker(sendMxc, title, info), + onToggleFavorite: (isMyStickers && isUserSticker) + ? () async { + final newFav = !(s['favorited'] == true); + await GifFavoriteService.setUserStickerFavorited(mxcSource, newFav); + if (mounted) setState(() {}); + } + : null, + ); + }, + ); + } + + Widget _buildPackGrid(String packId) { + if (_packLoading) { + return Center(child: CircularProgressIndicator(color: _pt!.accent)); + } + final stickers = (_packData[packId]?['stickers'] as List?) ?? []; + if (stickers.isEmpty) { + return Center(child: Text('Leer', style: TextStyle(color: _pt!.fgMuted))); + } + return GridView.builder( + padding: const EdgeInsets.all(4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, crossAxisSpacing: 3, mainAxisSpacing: 3, + ), + itemCount: stickers.length, + itemBuilder: (ctx, i) { + final s = stickers[i]; + final id = s['id']?.toString() ?? ''; + final thumbUrl = '$_stickerServerUrl/packs/$packId/${id}_thumb.png'; + final mxcUrl = (s['url'] as String?) ?? ''; + final body = (s['body'] as String?) ?? ''; + final mimetype = (s['info']?['mimetype'] as String?) ?? 'image/webp'; + final info = Map.from(s['info'] as Map? ?? {}); + + final isFav = GifFavoriteService.isStickerFavorite(mxcUrl); + + return GestureDetector( + onTap: () => _sendSticker(mxcUrl, body, info), + onLongPressStart: (details) { + final dx = details.globalPosition.dx; + final dy = details.globalPosition.dy; + final screen = MediaQuery.sizeOf(context); + showMenu( + context: context, + position: RelativeRect.fromLTRB( + dx, dy, screen.width - dx, screen.height - dy, + ), + items: [ + PopupMenuItem( + value: 'fav', + child: Row(children: [ + Icon( + isFav ? Icons.favorite_border : Icons.favorite, + size: 16, + color: isFav ? _pt!.fgMuted : Colors.red, + ), + const SizedBox(width: 8), + Text( + isFav ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', + style: TextStyle(color: _pt!.fg, fontSize: 14), + ), + ]), + ), + ], + ).then((val) async { + if (val == 'fav') { + await GifFavoriteService.togglePackStickerFavorite( + mxcUrl: mxcUrl, + thumbUrl: thumbUrl, + title: body, + mimetype: mimetype, + ); + if (mounted) setState(() {}); + } + }); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + thumbUrl, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Container(color: _pt!.bg2), + ), + ), + ); + }, + ); + } +} + +class _StickerCell extends StatefulWidget { + final String piUrl; + final bool isFavorited; + final bool showHeart; + final VoidCallback onTap; + final Future Function()? onToggleFavorite; + + const _StickerCell({ + required this.piUrl, + required this.isFavorited, + required this.showHeart, + required this.onTap, + this.onToggleFavorite, + }); + + @override + State<_StickerCell> createState() => _StickerCellState(); +} + +class _StickerCellState extends State<_StickerCell> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final showOverlay = widget.showHeart && (_hovered || widget.isFavorited); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + widget.piUrl, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Container(color: pt.bg2), + ), + if (showOverlay) + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: widget.onToggleFavorite, + behavior: HitTestBehavior.opaque, + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: Icon( + widget.isFavorited ? Icons.favorite : Icons.favorite_border, + color: widget.isFavorited ? Colors.red : Colors.white, + size: 13, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + final String title; + const _SectionLabel(this.title); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + child: Text(title, + style: TextStyle(color: PyramidColors.fgDim, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.6)), + ); + } +} + +class _GifCell extends StatelessWidget { + final String imageUrl; + final bool isFavorite; + final bool isHovered; + final ValueChanged onHoverChanged; + final VoidCallback onTap; + final VoidCallback onFavToggle; + + const _GifCell({ + required this.imageUrl, + required this.isFavorite, + required this.isHovered, + required this.onHoverChanged, + required this.onTap, + required this.onFavToggle, + }); + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => onHoverChanged(true), + onExit: (_) => onHoverChanged(false), + child: GestureDetector( + onTap: onTap, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Stack( + fit: StackFit.expand, + children: [ + Image.network(imageUrl, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container(color: PyramidTheme.of(context).bg3)), + if (isHovered || isFavorite) + Positioned( + top: 4, right: 4, + child: GestureDetector( + onTap: onFavToggle, + behavior: HitTestBehavior.opaque, + child: Container( + width: 22, height: 22, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: Icon( + isFavorite ? Icons.favorite : Icons.favorite_border, + color: isFavorite ? Colors.red : Colors.white, + size: 13, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/chat/media_player.dart b/lib/features/chat/media_player.dart new file mode 100644 index 0000000..d5236cf --- /dev/null +++ b/lib/features/chat/media_player.dart @@ -0,0 +1,904 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/features/chat/media_viewer.dart'; + +// ─── Audio Player ───────────────────────────────────────────────────────────── +// +// isVoice=true → compact row (Sprachnachricht in Chat) +// isVoice=false → defaultBars card mit Waveform (Audiodatei) + +class PyramidAudioPlayer extends StatefulWidget { + final Uint8List bytes; + final String filename; + final PyramidTheme pt; + final bool isVoice; + + const PyramidAudioPlayer({ + super.key, + required this.bytes, + required this.filename, + required this.pt, + this.isVoice = false, + }); + + @override + State createState() => _PyramidAudioPlayerState(); +} + +class _PyramidAudioPlayerState extends State { + late final Player _player; + bool _loading = true; + double _speed = 1.0; + + static const _speeds = [1.0, 1.5, 2.0]; + + @override + void initState() { + super.initState(); + _player = Player(); + _init(); + } + + Future _init() async { + final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'ogg'; + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/pyr_audio_${widget.bytes.hashCode}.$ext'); + await file.writeAsBytes(widget.bytes); + await _player.open(Media('file://${file.path}'), play: false); + if (mounted) setState(() => _loading = false); + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + String _fmt(Duration d) { + final m = d.inMinutes.remainder(60); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + void _seek(Duration to) => _player.seek(to); + void _skipBack() => _player.seek(_player.state.position - const Duration(seconds: 10)); + void _skipForward() => _player.seek(_player.state.position + const Duration(seconds: 10)); + void _setSpeed(double s) { setState(() => _speed = s); _player.setRate(s); } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + if (_loading) return _buildLoading(pt); + + return StreamBuilder( + stream: _player.stream.playing, + builder: (_, playSnap) => StreamBuilder( + stream: _player.stream.position, + builder: (_, posSnap) => StreamBuilder( + stream: _player.stream.duration, + builder: (_, durSnap) => StreamBuilder( + stream: _player.stream.volume, + builder: (_, volSnap) { + final playing = playSnap.data ?? false; + final pos = posSnap.data ?? Duration.zero; + final dur = durSnap.data ?? Duration.zero; + final vol = (volSnap.data ?? 100.0) / 100.0; + return widget.isVoice + ? _buildCompact(pt, playing, pos, dur) + : _buildDefaultBars(pt, playing, pos, dur, vol); + }, + ), + ), + ), + ); + } + + Widget _buildLoading(PyramidTheme pt) => Container( + margin: const EdgeInsets.only(top: 4, bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + constraints: const BoxConstraints(maxWidth: 340), + decoration: BoxDecoration( + color: pt.bg1, border: Border.all(color: pt.border), + borderRadius: BorderRadius.circular(pt.rLg), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + SizedBox(width: 32, height: 32, + child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), + const SizedBox(width: 12), + Text('Lade…', style: TextStyle(color: pt.fgMuted, fontSize: 12)), + ]), + ); + + // ── Compact: Sprachnachricht ─────────────────────────────────────────────── + + Widget _buildCompact(PyramidTheme pt, bool playing, Duration pos, Duration dur) { + final progress = dur.inMilliseconds == 0 + ? 0.0 : pos.inMilliseconds / dur.inMilliseconds; + final title = widget.filename.contains('.') + ? widget.filename.split('.').first : widget.filename; + + return Container( + margin: const EdgeInsets.only(top: 4, bottom: 4), + padding: const EdgeInsets.fromLTRB(12, 12, 14, 12), + constraints: const BoxConstraints(maxWidth: 340), + decoration: BoxDecoration( + color: pt.bg1, border: Border.all(color: pt.border), + borderRadius: BorderRadius.circular(pt.rLg), + ), + child: Row(children: [ + _AArtwork(size: 36, accent: pt.accent), + const SizedBox(width: 10), + Flexible(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis), + Text('Sprachnachricht', style: TextStyle(color: pt.fgMuted, fontSize: 11)), + ], + )), + const SizedBox(width: 10), + _APlayButton(playing: playing, onTap: () => playing ? _player.pause() : _player.play(), + size: 32, accent: pt.accent, accentGlow: pt.accentGlow), + const SizedBox(width: 10), + Text(_fmt(pos), style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)), + const SizedBox(width: 8), + Expanded(child: _AScrubber(progress: progress, accent: pt.accent, track: pt.bg3, + onSeek: (f) => _seek(Duration(milliseconds: (dur.inMilliseconds * f).round())))), + const SizedBox(width: 8), + Text(_fmt(dur), style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)), + ]), + ); + } + + // ── DefaultBars: Audiodatei mit Waveform ─────────────────────────────────── + + Widget _buildDefaultBars(PyramidTheme pt, bool playing, Duration pos, Duration dur, double vol) { + final progress = dur.inMilliseconds == 0 ? 0.0 : pos.inMilliseconds / dur.inMilliseconds; + final nameParts = widget.filename.split('.'); + final title = nameParts.length > 1 + ? nameParts.sublist(0, nameParts.length - 1).join('.') : widget.filename; + final ext = nameParts.length > 1 ? nameParts.last.toUpperCase() : 'AUDIO'; + final remaining = pos <= dur ? dur - pos : Duration.zero; + + return Container( + margin: const EdgeInsets.only(top: 4, bottom: 4), + padding: const EdgeInsets.fromLTRB(20, 18, 20, 18), + constraints: const BoxConstraints(maxWidth: 380), + decoration: BoxDecoration( + color: pt.bg1, border: Border.all(color: pt.border), + borderRadius: BorderRadius.circular(pt.rLg), + ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + // ── Header ─────────────────────────────────────────────────────────── + Row(children: [ + _AArtwork(size: 48, accent: pt.accent), + const SizedBox(width: 14), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis), + Text(ext, style: TextStyle(color: pt.fgMuted, fontSize: 12)), + ], + )), + ]), + const SizedBox(height: 14), + + // ── Waveform ───────────────────────────────────────────────────────── + LayoutBuilder(builder: (_, c) => GestureDetector( + onTapDown: (d) { + final f = (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0); + _seek(Duration(milliseconds: (dur.inMilliseconds * f).round())); + }, + onPanUpdate: (d) { + final f = (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0); + _seek(Duration(milliseconds: (dur.inMilliseconds * f).round())); + }, + child: SizedBox(height: 40, child: CustomPaint( + painter: _ChunkyBarsPainter( + progress: progress, + played: pt.accent, unplayed: pt.bg3, cursor: pt.fg, + ), + size: Size.infinite, + )), + )), + const SizedBox(height: 8), + + // ── Zeitanzeige ────────────────────────────────────────────────────── + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Text(_fmt(pos), style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)), + Text('−${_fmt(remaining)}', style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)), + ]), + const SizedBox(height: 14), + + // ── Steuerung ──────────────────────────────────────────────────────── + Row(children: [ + Icon(Icons.volume_up_outlined, size: 16, color: pt.fgMuted), + const SizedBox(width: 6), + SizedBox(width: 64, child: _AMiniBar(value: vol, track: pt.bg3, fill: pt.fgMuted)), + const Spacer(), + _AIconBtn(icon: Icons.replay_10, color: pt.fgMuted, rSm: pt.rSm, onTap: _skipBack), + const SizedBox(width: 4), + _APlayButton( + playing: playing, + onTap: () => playing ? _player.pause() : _player.play(), + size: 40, accent: pt.accent, accentGlow: pt.accentGlow, + ), + const SizedBox(width: 4), + _AIconBtn(icon: Icons.forward_10, color: pt.fgMuted, rSm: pt.rSm, onTap: _skipForward), + const Spacer(), + ..._speeds.map((s) => _ASpeedPill( + label: '${s == s.toInt() ? s.toInt() : s}x', + active: _speed == s, + accent: pt.accent, accentSoft: pt.accentSoft, + bg: pt.bg2, fgMuted: pt.fgMuted, rSm: pt.rSm, + onTap: () => _setSpeed(s), + )), + ]), + ]), + ); + } +} + +// ─── Audio: Bausteine ──────────────────────────────────────────────────────── + +class _AArtwork extends StatelessWidget { + final double size; + final Color accent; + const _AArtwork({required this.size, required this.accent}); + + @override + Widget build(BuildContext context) { + final hsl = HSLColor.fromColor(accent); + final c1 = hsl.withLightness((hsl.lightness - 0.08).clamp(0.0, 1.0)).toColor(); + final c2 = hsl.withHue((hsl.hue + 30) % 360) + .withLightness((hsl.lightness - 0.18).clamp(0.0, 1.0)).toColor(); + return Container( + width: size, height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(size * 0.3), + gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [c1, c2]), + ), + child: Icon(Icons.music_note, color: Colors.white.withOpacity(0.85), size: size * 0.48), + ); + } +} + +class _APlayButton extends StatelessWidget { + final bool playing; + final VoidCallback? onTap; + final double size; + final Color accent, accentGlow; + const _APlayButton({required this.playing, this.onTap, required this.size, + required this.accent, required this.accentGlow}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: size, height: size, + decoration: BoxDecoration( + color: accent, shape: BoxShape.circle, + boxShadow: [BoxShadow(color: accentGlow, blurRadius: 16, offset: const Offset(0, 4))], + ), + child: Icon(playing ? Icons.pause : Icons.play_arrow, + color: PyramidColors.accentFg, size: size * 0.48), + ), + ); + } +} + +class _AIconBtn extends StatelessWidget { + final IconData icon; + final Color color; + final double rSm; + final VoidCallback? onTap; + const _AIconBtn({required this.icon, required this.color, required this.rSm, this.onTap}); + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(rSm), + child: SizedBox(width: 32, height: 32, + child: Icon(icon, size: 18, color: color)), + ); +} + +class _ASpeedPill extends StatelessWidget { + final String label; + final bool active; + final VoidCallback? onTap; + final Color accent, accentSoft, bg, fgMuted; + final double rSm; + const _ASpeedPill({required this.label, this.active = false, this.onTap, + required this.accent, required this.accentSoft, required this.bg, + required this.fgMuted, required this.rSm}); + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 1), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: active ? accentSoft : bg, + borderRadius: BorderRadius.circular(rSm), + ), + child: Text(label, style: TextStyle( + fontSize: 11, fontWeight: FontWeight.w500, + color: active ? accent : fgMuted, + letterSpacing: 0.2, + )), + ), + ); +} + +class _AScrubber extends StatelessWidget { + final double progress; + final Color accent, track; + final ValueChanged? onSeek; + const _AScrubber({required this.progress, required this.accent, required this.track, this.onSeek}); + + @override + Widget build(BuildContext context) => LayoutBuilder(builder: (_, c) => GestureDetector( + onTapDown: (d) => onSeek?.call((d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)), + onPanUpdate: (d) => onSeek?.call((d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)), + child: SizedBox(height: 14, child: Stack(alignment: Alignment.centerLeft, children: [ + Container(height: 3, decoration: BoxDecoration(color: track, borderRadius: BorderRadius.circular(2))), + FractionallySizedBox( + widthFactor: progress, + child: Container(height: 3, decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2))), + ), + ])), + )); +} + +class _AMiniBar extends StatelessWidget { + final double value; + final Color track, fill; + const _AMiniBar({required this.value, required this.track, required this.fill}); + + @override + Widget build(BuildContext context) => Stack(alignment: Alignment.centerLeft, children: [ + Container(height: 3, decoration: BoxDecoration(color: track, borderRadius: BorderRadius.circular(2))), + FractionallySizedBox( + widthFactor: value.clamp(0.0, 1.0), + child: Container(height: 3, decoration: BoxDecoration(color: fill, borderRadius: BorderRadius.circular(2))), + ), + ]); +} + +class _ChunkyBarsPainter extends CustomPainter { + final double progress; + final Color played, unplayed, cursor; + static const int _count = 56; + static final List _heights = _seed(); + + static List _seed() { + final r = math.Random(7); + return List.generate(_count, (i) { + final env = math.sin((i / _count) * math.pi) * 0.6 + 0.4; + return 0.2 + r.nextDouble() * 0.8 * env; + }); + } + + const _ChunkyBarsPainter({required this.progress, required this.played, + required this.unplayed, required this.cursor}); + + @override + void paint(Canvas canvas, Size s) { + const gap = 2.0; + final barW = (s.width - gap * (_count - 1)) / _count; + final cur = (progress * _count).floor(); + for (int i = 0; i < _count; i++) { + final h = _heights[i] * s.height; + final x = i * (barW + gap); + final y = (s.height - h) / 2; + final color = i == cur ? cursor : (i < cur ? played : unplayed); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(x, y, barW, h), const Radius.circular(1)), + Paint()..color = color, + ); + } + } + + @override + bool shouldRepaint(_ChunkyBarsPainter old) => + old.progress != progress || old.played != played; +} + +// ─── Video Player ───────────────────────────────────────────────────────────── +// +// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide) + +class PyramidVideoPlayer extends StatefulWidget { + final Uint8List bytes; + final String filename; + final PyramidTheme pt; + final String senderName; + + const PyramidVideoPlayer({ + super.key, + required this.bytes, + required this.filename, + required this.pt, + this.senderName = '', + }); + + @override + State createState() => _PyramidVideoPlayerState(); +} + +class _PyramidVideoPlayerState extends State { + late final Player _player; + late final VideoController _controller; + bool _loading = true; + double _speed = 1.0; + String _quality = 'Auto'; + + static const _speeds = [0.5, 1.0, 1.25, 1.5, 2.0]; + static const _qualities = ['Auto', '1080p', '720p', '480p', '360p']; + + @override + void initState() { + super.initState(); + _player = Player(); + _controller = VideoController(_player); + _init(); + } + + Future _init() async { + final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4'; + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/pyr_video_${widget.bytes.hashCode}.$ext'); + await file.writeAsBytes(widget.bytes); + await _player.open(Media('file://${file.path}'), play: false); + if (mounted) setState(() => _loading = false); + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + String _fmt(Duration d) { + final m = d.inMinutes.remainder(60); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + void _openFullscreen() => MediaViewer.show( + context, + senderName: widget.senderName, + filename: widget.filename, + bytes: widget.bytes, + isVideo: true, + ); + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final title = widget.filename.contains('.') + ? widget.filename.split('.').first : widget.filename; + + if (_loading) { + return Container( + margin: const EdgeInsets.only(top: 4, bottom: 4), + constraints: const BoxConstraints(maxWidth: 360), + height: 200, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(pt.rLg), + border: Border.all(color: pt.border), + ), + child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), + ); + } + + return Container( + margin: const EdgeInsets.only(top: 4, bottom: 4), + constraints: const BoxConstraints(maxWidth: 420), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(pt.rLg), + border: Border.all(color: pt.border), + ), + clipBehavior: Clip.hardEdge, + child: AspectRatio( + aspectRatio: 16 / 9, + child: StreamBuilder( + stream: _player.stream.playing, + builder: (_, playSnap) => StreamBuilder( + stream: _player.stream.position, + builder: (_, posSnap) => StreamBuilder( + stream: _player.stream.duration, + builder: (_, durSnap) { + final playing = playSnap.data ?? false; + final pos = posSnap.data ?? Duration.zero; + final dur = durSnap.data ?? Duration.zero; + final progress = dur.inMilliseconds == 0 + ? 0.0 : pos.inMilliseconds / dur.inMilliseconds; + + return GestureDetector( + onTap: () => playing ? _player.pause() : _player.play(), + child: Stack(fit: StackFit.expand, children: [ + // Video + Video(controller: _controller, controls: NoVideoControls), + + // Top: Titelleiste mit Farbverlauf + Align( + alignment: Alignment.topCenter, + child: _VTopBar(title: title, onFullscreen: _openFullscreen), + ), + + // Center: Play-Button wenn pausiert + if (!playing) + const Center(child: _VCenterPlay()), + + // Bottom: Steuerleiste (minimal = immer sichtbar) + Align( + alignment: Alignment.bottomCenter, + child: _VBottomBar( + playing: playing, pos: pos, dur: dur, + progress: progress, speed: _speed, quality: _quality, + speeds: _speeds, qualities: _qualities, fmt: _fmt, pt: pt, + onTogglePlay: () => playing ? _player.pause() : _player.play(), + onSeek: (d) => _player.seek(d), + onSkipBack: () => _player.seek(pos - const Duration(seconds: 10)), + onSkipForward: () => _player.seek(pos + const Duration(seconds: 10)), + onSpeed: (s) { setState(() => _speed = s); _player.setRate(s); }, + onQuality: (q) => setState(() => _quality = q), + onFullscreen: _openFullscreen, + ), + ), + ]), + ); + }, + ), + ), + ), + ), + ); + } +} + +// ─── Video: Bausteine ──────────────────────────────────────────────────────── + +class _VTopBar extends StatelessWidget { + final String title; + final VoidCallback? onFullscreen; + const _VTopBar({required this.title, this.onFullscreen}); + + @override + Widget build(BuildContext context) => Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(14, 12, 8, 20), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, end: Alignment.bottomCenter, + colors: [Color(0x99000000), Colors.transparent], + ), + ), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Expanded(child: Text(title, style: const TextStyle( + color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500, + ), overflow: TextOverflow.ellipsis)), + _VOverlayBtn(icon: Icons.fullscreen, onTap: onFullscreen), + ]), + ); +} + +class _VCenterPlay extends StatelessWidget { + const _VCenterPlay(); + @override + Widget build(BuildContext context) => Container( + width: 56, height: 56, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.10), + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withOpacity(0.18)), + ), + child: const Icon(Icons.play_arrow, color: Colors.white, size: 26), + ); +} + +class _VBottomBar extends StatelessWidget { + final bool playing; + final Duration pos, dur; + final double progress, speed; + final String quality; + final List speeds; + final List qualities; + final String Function(Duration) fmt; + final PyramidTheme pt; + final VoidCallback onTogglePlay, onSkipBack, onSkipForward, onFullscreen; + final ValueChanged onSeek; + final ValueChanged onSpeed; + final ValueChanged onQuality; + + const _VBottomBar({ + required this.playing, required this.pos, required this.dur, + required this.progress, required this.speed, required this.quality, + required this.speeds, required this.qualities, required this.fmt, + required this.pt, required this.onTogglePlay, required this.onSeek, + required this.onSkipBack, required this.onSkipForward, + required this.onSpeed, required this.onQuality, required this.onFullscreen, + }); + + @override + Widget build(BuildContext context) => Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 16, 12, 12), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, end: Alignment.topCenter, + colors: [Color(0xCC000000), Colors.transparent], + ), + ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _VScrubber(progress: progress, dur: dur, onSeek: onSeek, accent: pt.accent), + const SizedBox(height: 8), + Row(children: [ + _VPlayMini(playing: playing, onTap: onTogglePlay), + _VOverlayBtn(icon: Icons.replay_10, onTap: onSkipBack), + _VOverlayBtn(icon: Icons.forward_10, onTap: onSkipForward), + const SizedBox(width: 6), + Text('${fmt(pos)} / ${fmt(dur)}', + style: const TextStyle(color: Colors.white70, fontSize: 10, letterSpacing: 0.4)), + const Spacer(), + _VSettingsButton( + speed: speed, quality: quality, + speeds: speeds, qualities: qualities, + onSpeed: onSpeed, onQuality: onQuality, + ), + _VOverlayBtn(icon: Icons.fullscreen, onTap: onFullscreen), + ]), + ]), + ); +} + +class _VScrubber extends StatelessWidget { + final double progress; + final Duration dur; + final ValueChanged onSeek; + final Color accent; + const _VScrubber({required this.progress, required this.dur, + required this.onSeek, required this.accent}); + + @override + Widget build(BuildContext context) => LayoutBuilder(builder: (_, c) => GestureDetector( + onTapDown: (d) => onSeek(Duration( + milliseconds: (dur.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round())), + onPanUpdate: (d) => onSeek(Duration( + milliseconds: (dur.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round())), + child: SizedBox(height: 16, child: Stack(alignment: Alignment.centerLeft, children: [ + Container(height: 3, decoration: BoxDecoration( + color: Colors.white.withOpacity(0.20), borderRadius: BorderRadius.circular(2))), + FractionallySizedBox(widthFactor: progress, child: Container(height: 3, + decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2)))), + Positioned( + left: c.maxWidth * progress.clamp(0.0, 1.0) - 5, + child: Container(width: 10, height: 10, + decoration: BoxDecoration(color: accent, shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.4), blurRadius: 0, spreadRadius: 2)])), + ), + ])), + )); +} + +class _VPlayMini extends StatelessWidget { + final bool playing; + final VoidCallback? onTap; + const _VPlayMini({required this.playing, this.onTap}); + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: Container( + width: 34, height: 34, + decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), + child: Icon(playing ? Icons.pause : Icons.play_arrow, + color: const Color(0xFF0A0A0C), size: 16), + ), + ); +} + +class _VOverlayBtn extends StatelessWidget { + final IconData icon; + final VoidCallback? onTap; + const _VOverlayBtn({required this.icon, this.onTap}); + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: SizedBox(width: 32, height: 32, + child: Icon(icon, size: 17, color: Colors.white.withOpacity(0.85))), + ); +} + +class _VSettingsButton extends StatelessWidget { + final double speed; + final String quality; + final List speeds; + final List qualities; + final ValueChanged onSpeed; + final ValueChanged onQuality; + const _VSettingsButton({required this.speed, required this.quality, + required this.speeds, required this.qualities, + required this.onSpeed, required this.onQuality}); + + String get _speedLabel { + final s = speed; + return '${s == s.toInt() ? s.toInt() : s}x'; + } + + @override + Widget build(BuildContext context) => PopupMenuButton( + tooltip: 'Einstellungen', + offset: const Offset(0, -8), + position: PopupMenuPosition.over, + color: const Color(0xF014141A), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: Colors.white.withOpacity(0.1)), + ), + itemBuilder: (_) => [PopupMenuItem( + enabled: false, padding: EdgeInsets.zero, + child: _VSettingsMenu( + speed: _speedLabel, quality: quality, + speeds: speeds, qualities: qualities, + onSpeed: onSpeed, onQuality: onQuality, + ), + )], + child: SizedBox(width: 32, height: 32, + child: Icon(Icons.settings_outlined, size: 16, color: Colors.white.withOpacity(0.85))), + ); +} + +class _VSettingsMenu extends StatefulWidget { + final String speed, quality; + final List speeds; + final List qualities; + final ValueChanged onSpeed; + final ValueChanged onQuality; + const _VSettingsMenu({required this.speed, required this.quality, + required this.speeds, required this.qualities, + required this.onSpeed, required this.onQuality}); + + @override + State<_VSettingsMenu> createState() => _VSettingsMenuState(); +} + +class _VSettingsMenuState extends State<_VSettingsMenu> { + String? _section; // null | 'speed' | 'quality' + + static const _fg = Color(0xE5FFFFFF); + static const _dim = Color(0x99FFFFFF); + + @override + Widget build(BuildContext context) { + if (_section == null) { + return SizedBox( + width: 190, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _row('Geschwindigkeit', widget.speed, () => setState(() => _section = 'speed')), + _row('Qualität', widget.quality, () => setState(() => _section = 'quality')), + ]), + ); + } + final isSpeed = _section == 'speed'; + return SizedBox( + width: 170, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + InkWell( + onTap: () => setState(() => _section = null), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.arrow_back, size: 12, color: _dim), + const SizedBox(width: 8), + Text(isSpeed ? 'GESCHWINDIGKEIT' : 'QUALITÄT', + style: const TextStyle(color: _dim, fontSize: 10, letterSpacing: 0.8)), + ]), + ), + ), + Container(height: 1, color: Colors.white.withOpacity(0.08)), + if (isSpeed) + ...widget.speeds.map((s) { + final lbl = '${s == s.toInt() ? s.toInt() : s}x'; + return _item(lbl, lbl == widget.speed, () => widget.onSpeed(s)); + }) + else + ...widget.qualities.map((q) => _item(q, q == widget.quality, () => widget.onQuality(q))), + ]), + ); + } + + Widget _row(String label, String value, VoidCallback onTap) => InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text(label, style: const TextStyle(color: _fg, fontSize: 12)), + const SizedBox(width: 16), + const Spacer(), + Text(value, style: const TextStyle(color: _dim, fontSize: 11, letterSpacing: 0.3)), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 14, color: _dim), + ]), + ), + ); + + Widget _item(String label, bool active, VoidCallback onTap) => InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + SizedBox(width: 18, child: active + ? const Icon(Icons.check, size: 14, color: PyramidColors.accentAmber) + : null), + Text(label, style: TextStyle( + color: active ? PyramidColors.accentAmber : _fg, fontSize: 12, + )), + ]), + ), + ); +} + +// ─── Fullscreen Video Player ────────────────────────────────────────────────── +// Verwendet AdaptiveVideoControls für native System-Fullscreen-Integration + +class FullscreenVideoPlayer extends StatefulWidget { + final Uint8List bytes; + final String filename; + + const FullscreenVideoPlayer({super.key, required this.bytes, required this.filename}); + + @override + State createState() => _FullscreenVideoPlayerState(); +} + +class _FullscreenVideoPlayerState extends State { + late final Player _player; + late final VideoController _controller; + bool _loading = true; + + @override + void initState() { + super.initState(); + _player = Player(); + _controller = VideoController(_player); + _init(); + } + + Future _init() async { + final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4'; + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/pyr_vfull_${widget.bytes.hashCode}.$ext'); + await file.writeAsBytes(widget.bytes); + await _player.open(Media('file://${file.path}'), play: true); + if (mounted) setState(() => _loading = false); + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_loading) return const Center(child: CircularProgressIndicator(color: Colors.white)); + return Video(controller: _controller, controls: AdaptiveVideoControls); + } +} diff --git a/lib/features/chat/media_viewer.dart b/lib/features/chat/media_viewer.dart new file mode 100644 index 0000000..571bc59 --- /dev/null +++ b/lib/features/chat/media_viewer.dart @@ -0,0 +1,255 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/features/chat/media_player.dart'; +import 'package:pyramid/utils/gif_favorite_service.dart'; + +/// Overlay viewer for images, GIFs and videos. +/// Uses a semi-transparent backdrop; tapping outside the content closes it. +class MediaViewer extends StatefulWidget { + final String senderName; + final Uint8List? bytes; + final String? networkUrl; + final String filename; + final bool isVideo; + final Event? event; + + const MediaViewer({ + super.key, + required this.senderName, + required this.filename, + this.bytes, + this.networkUrl, + this.isVideo = false, + this.event, + }); + + static Future show( + BuildContext context, { + required String senderName, + required String filename, + Uint8List? bytes, + String? networkUrl, + bool isVideo = false, + Event? event, + }) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Schließen', + barrierColor: Colors.black.withAlpha(178), + transitionDuration: const Duration(milliseconds: 180), + transitionBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + pageBuilder: (context, animation, secondaryAnimation) => MediaViewer( + senderName: senderName, + filename: filename, + bytes: bytes, + networkUrl: networkUrl, + isVideo: isVideo, + event: event, + ), + ); + } + + @override + State createState() => _MediaViewerState(); +} + +class _MediaViewerState extends State { + final _tc = TransformationController(); + bool _controlsVisible = true; + bool _saving = false; + bool _savingSticker = false; + + @override + void dispose() { + _tc.dispose(); + super.dispose(); + } + + void _toggle() => setState(() => _controlsVisible = !_controlsVisible); + + void _resetZoom() => _tc.value = Matrix4.identity(); + + Future _saveAsSticker() async { + final event = widget.event; + if (event == null || _savingSticker) return; + setState(() => _savingSticker = true); + final messenger = ScaffoldMessenger.maybeOf(context); + try { + final ok = await GifFavoriteService.addImageAsStickerFavorite(event); + messenger?.showSnackBar(SnackBar( + content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'), + duration: const Duration(seconds: 2), + )); + } finally { + if (mounted) setState(() => _savingSticker = false); + } + } + + Future _save() async { + final b = widget.bytes; + if (b == null || _saving) return; + setState(() => _saving = true); + try { + Directory? dir; + if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + dir = await getDownloadsDirectory(); + } + dir ??= await getTemporaryDirectory(); + final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'jpg'; + final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext'; + final file = File('${dir.path}/$name'); + await file.writeAsBytes(b); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Gespeichert: ${file.path}'), + duration: const Duration(seconds: 4), + )); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Fehler: $e')), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final isVideo = widget.isVideo && widget.bytes != null; + final size = MediaQuery.sizeOf(context); + + final header = AnimatedOpacity( + opacity: _controlsVisible ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: IgnorePointer( + ignoring: !_controlsVisible, + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black54, Colors.transparent], + ), + ), + child: SafeArea( + bottom: false, + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded, color: Colors.white), + tooltip: 'Schließen', + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(widget.senderName, + style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600)), + Text(widget.filename, + style: const TextStyle(color: Colors.white60, fontSize: 12), + overflow: TextOverflow.ellipsis), + ], + ), + ), + if (widget.event != null && !isVideo) + IconButton( + onPressed: _savingSticker ? null : _saveAsSticker, + tooltip: 'Als Sticker speichern', + icon: _savingSticker + ? const SizedBox(width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.sticky_note_2_outlined, color: Colors.white), + ), + if (widget.bytes != null) + IconButton( + onPressed: _saving ? null : _save, + tooltip: 'Speichern', + icon: _saving + ? const SizedBox(width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.download_rounded, color: Colors.white), + ), + if (!isVideo) + IconButton( + onPressed: _resetZoom, + tooltip: 'Zoom zurücksetzen', + icon: const Icon(Icons.zoom_out_map_rounded, color: Colors.white), + ), + ], + ), + ), + ), + ), + ); + + // Content area — constrained so the transparent Material outside it lets + // pointer events through to the barrier (which dismisses on tap). + final contentMaxW = (size.width - 48).clamp(200.0, 1200.0); + final contentMaxH = (size.height - 48).clamp(200.0, 900.0); + + if (isVideo) { + return Material( + type: MaterialType.transparency, + child: Center( + child: SizedBox( + width: contentMaxW, + height: contentMaxH, + child: Stack( + fit: StackFit.expand, + children: [ + FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename), + Positioned(top: 0, left: 0, right: 0, child: header), + ], + ), + ), + ), + ); + } + + Widget img; + if (widget.bytes != null) { + img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true); + } else if (widget.networkUrl != null) { + img = Image.network(widget.networkUrl!, fit: BoxFit.contain); + } else { + img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim); + } + + return Material( + type: MaterialType.transparency, + child: Center( + child: SizedBox( + width: contentMaxW, + height: contentMaxH, + child: GestureDetector( + onTap: _toggle, + child: Stack( + fit: StackFit.expand, + children: [ + InteractiveViewer( + transformationController: _tc, + minScale: 0.5, + maxScale: 10.0, + child: Center(child: img), + ), + Positioned(top: 0, left: 0, right: 0, child: header), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/chat/message_group.dart b/lib/features/chat/message_group.dart new file mode 100644 index 0000000..c55de5d --- /dev/null +++ b/lib/features/chat/message_group.dart @@ -0,0 +1,2780 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pyramid/features/chat/media_player.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/media_cache.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/features/chat/emoji_picker.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:pyramid/features/chat/document_viewer.dart'; +import 'package:pyramid/features/chat/media_viewer.dart'; +import 'package:pyramid/features/chat/reaction_service.dart'; +import 'package:pyramid/utils/gif_favorite_service.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; +import 'package:url_launcher/url_launcher.dart'; + +// Wraps a group of consecutive messages from the same sender +class MessageGroup extends StatefulWidget { + final List events; + final String currentUserId; + final Room? room; + final Timeline? timeline; + final VoidCallback? onReply; + final Set selectedIds; + final ValueChanged? onToggleSelect; + final bool showStatusIndicator; + /// Event IDs that another member has read up to (their last-read position). + /// A ✓✓ is rendered at any of this group's events contained here. + final Set readEventIds; + /// When non-null and pointing to a non-last event in this group, an inline + /// "Neue Nachrichten" divider is rendered after that event's row. + final String? fullyReadId; + /// Jump-to-message (from search): the row matching [jumpEventId] gets + /// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted. + final String? jumpEventId; + final GlobalKey? jumpKey; + final String? highlightEventId; + final bool highlightOn; + + const MessageGroup({ + super.key, + required this.events, + required this.currentUserId, + this.room, + this.timeline, + this.onReply, + this.selectedIds = const {}, + this.onToggleSelect, + this.showStatusIndicator = false, + this.readEventIds = const {}, + this.fullyReadId, + this.jumpEventId, + this.jumpKey, + this.highlightEventId, + this.highlightOn = false, + }); + + @override + State createState() => _MessageGroupState(); +} + +class _MessageGroupState extends State { + // Per-message hover state (desktop): which event row the pointer is over, and + // which row currently has its reaction-picker overlay open. + String? _hoveredEventId; + String? _overlayEventId; + double _swipeOffset = 0; + static const _swipeThreshold = 56.0; + bool _swipeTriggered = false; + + bool get _isMobile => Platform.isAndroid || Platform.isIOS; + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final firstEvent = widget.events.first; + final sender = firstEvent.senderFromMemoryOrFallback; + final name = sender.calcDisplayname(); + final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; + final color = _colorFromId(firstEvent.senderId); + final time = _formatTime(firstEvent.originServerTs); + final avatarUri = sender.avatarUrl; + + // Build a FLAT column of rows — each message at the same indentation level. + // First event row: avatar + name + time + content + // Continuation rows: timestamp-slot + content + // This prevents the nested-Row double-indent bug. + final rows = []; + + // ── First event row ────────────────────────────────── + // Each message row is INDIVIDUALLY selectable (see _selectableRow) so two + // consecutive messages from the same sender aren't selected as one block. + rows.add(_selectableRow(firstEvent, pt, Padding( + padding: EdgeInsets.only(top: pt.msgGap / 2, left: 20, right: 20, bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + SizedBox( + width: 38, + child: Column(children: [ + const SizedBox(height: 2), + _Avatar(initial: initial, color: color, pt: pt, size: 38, avatarUri: avatarUri), + ]), + ), + const SizedBox(width: 14), + // Name + time + message + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Text(name, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(width: 8), + Text(time, style: TextStyle(color: pt.fgDim, fontSize: 11)), + ]), + const SizedBox(height: 2), + _MessageBody( + event: firstEvent, + pt: pt, + timeline: widget.timeline, + room: widget.room, + currentUserId: widget.currentUserId, + ), + ], + ), + ), + ], + ), + ))); + if (widget.readEventIds.contains(firstEvent.eventId)) rows.add(_ReadMarker(pt: pt)); + // Inline unread divider after the first event when it's the fullyRead + // marker and there are more events in this group. + if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) { + rows.add(_InlineUnreadDivider(pt: pt)); + } + + // ── Continuation event rows (siblings, same indentation) ── + for (var j = 1; j < widget.events.length; j++) { + final event = widget.events[j]; + rows.add(_selectableRow(event, pt, Padding( + padding: const EdgeInsets.only(top: 8, left: 20, right: 20), + child: _ContinuationMessage( + event: event, + pt: pt, + timeline: widget.timeline, + room: widget.room, + currentUserId: widget.currentUserId, + ), + ))); + if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt)); + // Inline divider after this event if it's the fullyRead and not the last. + if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) { + rows.add(_InlineUnreadDivider(pt: pt)); + } + } + + // Sending/sent indicator on the newest own message — only when no read ✓✓ + // is already shown for it (the ✓✓ implies it was delivered + read). + if (widget.showStatusIndicator && + firstEvent.senderId == widget.currentUserId && + widget.room != null && + !widget.readEventIds.contains(widget.events.last.eventId)) { + rows.add(Padding( + padding: const EdgeInsets.only(right: 24, bottom: 1), + child: Align( + alignment: Alignment.centerRight, + child: _MessageStatus( + lastEvent: widget.events.last, + pt: pt, + ), + ), + )); + } + rows.add(const SizedBox(height: 2)); + + // Hover highlight + actions are now per-row (see _selectableRow), so the + // group is just the column of rows. + Widget content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: rows, + ); + + if (!_isMobile) return content; + + return GestureDetector( + onHorizontalDragUpdate: (d) { + if (d.delta.dx > 0) return; // Only allow right-to-left swipe for reply + setState(() { + _swipeOffset = (_swipeOffset + d.delta.dx).clamp(-_swipeThreshold, 0.0); + if (_swipeOffset <= -_swipeThreshold && !_swipeTriggered) { + _swipeTriggered = true; + HapticFeedback.mediumImpact(); + } + }); + }, + onHorizontalDragEnd: (_) { + if (_swipeTriggered) widget.onReply?.call(); + setState(() { _swipeOffset = 0; _swipeTriggered = false; }); + }, + child: Stack( + children: [ + Transform.translate( + offset: Offset(_swipeOffset, 0), + child: content, + ), + if (_swipeOffset < -8) + Positioned( + right: 12, + top: 0, + bottom: 0, + child: Center( + child: AnimatedOpacity( + opacity: (-_swipeOffset / _swipeThreshold).clamp(0.0, 1.0), + duration: const Duration(milliseconds: 60), + child: Icon(Icons.reply_rounded, size: 20, color: pt.fgMuted), + ), + ), + ), + ], + ), + ); + } + + // Wraps a single message row so it can be selected independently of the rest + // of the sender's group. Long-press enters selection mode; in selection mode a + // full-row tap overlay toggles this message's selection. + Widget _selectableRow(Event event, PyramidTheme pt, Widget child) { + final selected = widget.selectedIds.contains(event.eventId); + final inSel = widget.selectedIds.isNotEmpty; + final showActions = !_isMobile && !inSel && + (_hoveredEventId == event.eventId || _overlayEventId == event.eventId); + final isJumpTarget = widget.jumpEventId == event.eventId; + final isHighlighted = widget.highlightEventId == event.eventId; + + Widget row = GestureDetector( + key: isJumpTarget ? widget.jumpKey : null, + onLongPress: () => widget.onToggleSelect?.call(event.eventId), + child: Stack( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 100), + color: selected + ? pt.accent.withAlpha(40) + : (showActions ? pt.bg2 : Colors.transparent), + child: child, + ), + // Jump-from-search highlight — fades out via AnimatedOpacity. + if (isHighlighted) + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + opacity: widget.highlightOn ? 1.0 : 0.0, + duration: const Duration(milliseconds: 900), + curve: Curves.easeOut, + child: Container(color: pt.accent.withAlpha(48)), + ), + ), + ), + if (selected) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: Container(width: 3, color: pt.accent), + ), + if (showActions) + Positioned( + top: 0, + right: 32, + child: _HoverActions( + pt: pt, + event: event, + events: [event], + room: widget.room, + currentUserId: widget.currentUserId, + onReply: widget.onReply, + onOverlayStateChanged: (active) => setState( + () => _overlayEventId = active ? event.eventId : null), + ), + ), + if (inSel) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => widget.onToggleSelect?.call(event.eventId), + ), + ), + ], + ), + ); + + if (_isMobile) return row; + + // Desktop: per-row hover highlight + actions. + return MouseRegion( + onEnter: (_) => setState(() => _hoveredEventId = event.eventId), + onExit: (_) => setState(() { + if (_hoveredEventId == event.eventId) _hoveredEventId = null; + }), + child: row, + ); + } + + Color _colorFromId(String id) { + const colors = [ + Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981), + Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B), + Color(0xFFFF6B9D), + ]; + if (id.isEmpty) return colors[0]; + return colors[id.codeUnitAt(0) % colors.length]; + } + + String _formatTime(DateTime t) { + final h = t.hour.toString().padLeft(2, '0'); + final m = t.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } +} + +// Shown under a message whose send failed (e.g. offline) — keeps the message +// visible with retry / delete instead of letting it silently disappear. +class _SendFailedRow extends StatelessWidget { + final Event event; + final PyramidTheme pt; + const _SendFailedRow({required this.event, required this.pt}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 3, bottom: 1), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline_rounded, size: 13, color: pt.danger), + const SizedBox(width: 4), + Text('Nicht gesendet', style: TextStyle(color: pt.danger, fontSize: 12)), + const SizedBox(width: 10), + _link('Wiederholen', pt, () async { + try { await event.sendAgain(); } catch (_) {} + }), + const SizedBox(width: 10), + _link('Löschen', pt, () async { + try { await event.cancelSend(); } catch (_) {} + }), + ], + ), + ); + } + + Widget _link(String label, PyramidTheme pt, VoidCallback onTap) => GestureDetector( + onTap: onTap, + child: Text( + label, + style: TextStyle( + color: pt.accent, + fontSize: 12, + decoration: TextDecoration.underline, + decorationColor: pt.accent, + ), + ), + ); +} + +// Single message (first in group) with full avatar + name shown +class _MessageBody extends StatelessWidget { + final Event event; + final PyramidTheme pt; + final Timeline? timeline; + final Room? room; + final String currentUserId; + + const _MessageBody({ + required this.event, + required this.pt, + this.timeline, + this.room, + required this.currentUserId, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MessageContent(event: event, pt: pt, timeline: timeline), + if (event.status.isError) _SendFailedRow(event: event, pt: pt), + if (timeline != null) _ReactionsRow(event: event, timeline: timeline!, room: room, currentUserId: currentUserId, pt: pt), + ], + ); + } +} + +// Continuation message — no avatar, just indented text with timestamp on hover +class _ContinuationMessage extends StatefulWidget { + final Event event; + final PyramidTheme pt; + final Timeline? timeline; + final Room? room; + final String currentUserId; + + const _ContinuationMessage({ + required this.event, + required this.pt, + this.timeline, + this.room, + required this.currentUserId, + }); + + @override + State<_ContinuationMessage> createState() => _ContinuationMessageState(); +} + +class _ContinuationMessageState extends State<_ContinuationMessage> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Timestamp on hover (in avatar column space) + SizedBox( + width: 38, + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: AnimatedOpacity( + opacity: _hovered ? 1 : 0, + duration: const Duration(milliseconds: 120), + child: Text( + _formatTime(widget.event.originServerTs), + textAlign: TextAlign.center, + style: TextStyle( + color: widget.pt.fgDim, + fontSize: 10, + ), + ), + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MessageContent(event: widget.event, pt: widget.pt, timeline: widget.timeline), + if (widget.event.status.isError) _SendFailedRow(event: widget.event, pt: widget.pt), + if (widget.timeline != null) + _ReactionsRow( + event: widget.event, + timeline: widget.timeline!, + room: widget.room, + currentUserId: widget.currentUserId, + pt: widget.pt, + ), + ], + ), + ), + ], + ), + ); + } + + String _formatTime(DateTime t) { + final h = t.hour.toString().padLeft(2, '0'); + final m = t.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } +} + +class _MessageContent extends StatelessWidget { + final Event event; + final PyramidTheme pt; + final Timeline? timeline; + + const _MessageContent({required this.event, required this.pt, this.timeline}); + + // Resolves edits: returns the display event (last edit) for this event. + Event get _displayEvent => + timeline != null ? event.getDisplayEvent(timeline!) : event; + + String? _getReplyId() { + final relatesTo = event.content.tryGetMap('m.relates_to'); + final replyMap = relatesTo?.tryGetMap('m.in_reply_to'); + return replyMap?['event_id'] as String?; + } + + String _stripReplyFallback(String body) { + final norm = body.replaceAll('\r\n', '\n'); + if (!norm.startsWith('> ') && !norm.startsWith('>\n')) return norm; + final lines = norm.split('\n'); + var i = 0; + while (i < lines.length && (lines[i].startsWith('> ') || lines[i].trimRight() == '>')) { + i++; + } + if (i < lines.length && lines[i].trim().isEmpty) i++; + if (i >= lines.length) return norm; + return lines.skip(i).join('\n').trim(); + } + + Event? _findReplyEvent(String replyId) { + if (timeline == null) return null; + try { + return timeline!.events.firstWhere((e) => e.eventId == replyId); + } catch (_) { + return null; + } + } + + @override + Widget build(BuildContext context) { + final displayEvent = _displayEvent; + final isEdited = displayEvent.eventId != event.eventId; + + // Still encrypted (decryption pending or failed) + if (displayEvent.type == EventTypes.Encrypted) { + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4, + children: [ + Icon(Icons.lock_outline_rounded, size: 13, color: pt.fgDim), + Text( + 'Nachricht wird entschlüsselt…', + style: TextStyle(color: pt.fgDim, fontSize: 13, fontStyle: FontStyle.italic), + ), + if (timeline != null) + Builder( + builder: (ctx) => GestureDetector( + onTap: () { + timeline!.requestKeys(); + ScaffoldMessenger.of(ctx).showSnackBar( + const SnackBar( + content: Text('Schlüssel werden von anderen Geräten angefordert…'), + duration: Duration(seconds: 3), + ), + ); + }, + child: Text( + 'Schlüssel anfordern', + style: TextStyle( + color: pt.accent, + fontSize: 12, + decoration: TextDecoration.underline, + decorationColor: pt.accent, + ), + ), + ), + ), + ], + ), + ); + } + + if (displayEvent.type == EventTypes.Sticker) { + return _MediaContent(event: displayEvent, pt: pt); + } + + final msgType = displayEvent.messageType; + + if (msgType == 'm.image') { + final ext = displayEvent.body.contains('.') + ? displayEvent.body.split('.').last.toLowerCase() + : ''; + const kImageExts = { + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', + 'tiff', 'tif', 'heic', 'heif', 'avif', 'ico', 'jfif', + }; + // SVG kann Image.memory nicht dekodieren — über den SVG-Renderer anzeigen. + if (ext == 'svg' || + displayEvent.content + .tryGetMap('info')?['mimetype'] == + 'image/svg+xml') { + return _DocPreviewContent(event: displayEvent, pt: pt); + } + if (ext.isNotEmpty && !kImageExts.contains(ext)) { + return _FileContent(event: displayEvent, pt: pt, icon: Icons.insert_drive_file_rounded); + } + return _ImageContent(event: displayEvent, pt: pt); + } + if (msgType == 'm.audio') { + return _AudioContent(event: displayEvent, pt: pt); + } + if (msgType == 'm.video') { + return _VideoContent(event: displayEvent, pt: pt); + } + if (msgType == 'm.file') { + if (DocumentViewer.isInlinePreviewable(displayEvent.body)) { + return _DocPreviewContent(event: displayEvent, pt: pt); + } + if (DocumentViewer.showsInlineCard(displayEvent.body)) { + return _ProprietaryInlineCard(event: displayEvent, pt: pt); + } + return _FileContent(event: displayEvent, pt: pt, icon: Icons.insert_drive_file_rounded); + } + + final replyId = _getReplyId(); + final replyEvent = replyId != null ? _findReplyEvent(replyId) : null; + final plainBody = replyId != null + ? _stripReplyFallback(displayEvent.body) + : displayEvent.body; + + // Use formatted_body (HTML) if the event has one, otherwise plain text. + final format = displayEvent.content.tryGet('format'); + // Strip the Matrix reply fallback block — we render + // our own quote card (_ReplyPreview) above, so the inline "In reply to @user" + // would be redundant and ugly. + final formattedBody = displayEvent.formattedText.replaceAll( + RegExp(r'.*?', caseSensitive: false, dotAll: true), + '', + ).trim(); + final hasHtml = format == 'org.matrix.custom.html' && formattedBody.isNotEmpty; + + final firstUrl = _firstUrl(plainBody); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (replyEvent != null) _ReplyPreview(replyEvent: replyEvent, pt: pt), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: hasHtml + ? _MatrixHtmlText( + html: formattedBody, + baseStyle: TextStyle(color: pt.fg, fontSize: 14, height: 1.55), + linkColor: pt.accent, + ) + : _PlainLinkText( + text: plainBody, + baseStyle: TextStyle(color: pt.fg, fontSize: 14, height: 1.55), + linkColor: pt.accent, + ), + ), + if (isEdited) + Text( + '(bearbeitet)', + style: TextStyle(color: pt.fgDim, fontSize: 11, fontStyle: FontStyle.italic), + ), + if (firstUrl != null) _LinkPreview(url: firstUrl, pt: pt), + ], + ); + } + + // Extracts the first http(s) URL in the text, for link-preview fetching. + static final RegExp _urlRegex = RegExp(r'https?://[^\s<>()\[\]]+', caseSensitive: false); + String? _firstUrl(String text) { + final m = _urlRegex.firstMatch(text); + if (m == null) return null; + var url = m.group(0)!; + // Trim common trailing punctuation that isn't part of the URL. + url = url.replaceAll(RegExp(r'[.,;:!?]+$'), ''); + return url; + } +} + +// ─── Link preview card (Open-Graph, WhatsApp/Discord-style) ────────────────── + +class _LinkPreviewData { + final String? title; + final String? description; + final String? imageMxc; + const _LinkPreviewData({this.title, this.description, this.imageMxc}); + bool get isEmpty => + (title == null || title!.isEmpty) && + (description == null || description!.isEmpty) && + imageMxc == null; +} + +class _LinkPreview extends ConsumerStatefulWidget { + final String url; + final PyramidTheme pt; + const _LinkPreview({required this.url, required this.pt}); + + // url → preview (null = fetched but nothing usable). Avoids refetching on + // every rebuild/scroll. + static final Map _cache = {}; + + @override + ConsumerState<_LinkPreview> createState() => _LinkPreviewState(); +} + +class _LinkPreviewState extends ConsumerState<_LinkPreview> { + _LinkPreviewData? _data; + bool _done = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + if (_LinkPreview._cache.containsKey(widget.url)) { + setState(() { _data = _LinkPreview._cache[widget.url]; _done = true; }); + return; + } + try { + final client = await ref.read(matrixClientProvider.future); + final hs = client.homeserver.toString(); + final enc = Uri.encodeQueryComponent(widget.url); + final headers = {'authorization': 'Bearer ${client.accessToken}'}; + + var res = await client.httpClient.get( + Uri.parse('$hs/_matrix/client/v1/media/preview_url?url=$enc'), + headers: headers, + ); + if (res.statusCode == 404) { + // Legacy fallback for older servers. + res = await client.httpClient.get( + Uri.parse('$hs/_matrix/media/v3/preview_url?url=$enc'), + headers: headers, + ); + } + _LinkPreviewData? data; + if (res.statusCode == 200) { + final json = jsonDecode(utf8.decode(res.bodyBytes)) as Map; + data = _LinkPreviewData( + title: (json['og:title'] as String?)?.trim(), + description: (json['og:description'] as String?)?.trim(), + imageMxc: json['og:image'] as String?, + ); + if (data.isEmpty) data = null; + } + _LinkPreview._cache[widget.url] = data; + if (mounted) setState(() { _data = data; _done = true; }); + } catch (_) { + _LinkPreview._cache[widget.url] = null; + if (mounted) setState(() { _data = null; _done = true; }); + } + } + + @override + Widget build(BuildContext context) { + final data = _data; + if (!_done || data == null) return const SizedBox.shrink(); + final pt = widget.pt; + final client = ref.watch(matrixClientProvider).valueOrNull; + + return Padding( + padding: const EdgeInsets.only(top: 6, bottom: 2), + child: GestureDetector( + onTap: () => launchUrl(Uri.parse(widget.url), mode: LaunchMode.externalApplication), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: ClipRRect( + borderRadius: BorderRadius.circular(pt.rBase), + child: Container( + decoration: BoxDecoration( + color: pt.bg2, + border: Border(left: BorderSide(color: pt.accent, width: 3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (data.imageMxc != null && client != null) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 180), + child: SizedBox( + width: double.infinity, + child: MxcImage( + mxcUri: data.imageMxc!, + client: client, + fit: BoxFit.cover, + placeholder: const SizedBox.shrink(), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (data.title != null && data.title!.isNotEmpty) + Text( + data.title!, + style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (data.description != null && data.description!.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + data.description!, + style: TextStyle(color: pt.fgMuted, fontSize: 12, height: 1.35), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _ReplyPreview extends StatelessWidget { + final Event replyEvent; + final PyramidTheme pt; + const _ReplyPreview({required this.replyEvent, required this.pt}); + + @override + Widget build(BuildContext context) { + final senderName = replyEvent.senderFromMemoryOrFallback.calcDisplayname(); + + // Media-aware preview: show an icon + label instead of a raw filename, + // matching WhatsApp/Discord. Falls back to stripped text for m.text. + final (IconData? icon, String body) = _previewFor(replyEvent); + + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: IntrinsicHeight( + child: ClipRRect( + borderRadius: BorderRadius.circular(pt.rSm), + child: Container( + color: pt.accentSoft, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Accent bar flush with the left edge of the tinted card. + Container(width: 3, color: pt.accent), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 5, 10, 5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(senderName, style: TextStyle(color: pt.accent, fontSize: 12, fontWeight: FontWeight.w600)), + Row( + children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: pt.fgMuted), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + body, + style: TextStyle(color: pt.fgMuted, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + (IconData?, String) _previewFor(Event e) { + switch (e.messageType) { + case 'm.image': + return (Icons.image_outlined, 'Bild'); + case 'm.video': + return (Icons.videocam_outlined, 'Video'); + case 'm.audio': + return (Icons.mic_none_rounded, 'Sprachnachricht'); + case 'm.file': + return (Icons.insert_drive_file_outlined, e.body.isNotEmpty ? e.body : 'Datei'); + } + if (e.type == EventTypes.Sticker) return (Icons.emoji_emotions_outlined, 'Sticker'); + + // Plain text: strip nested reply fallback, collapse to first line. + String body = e.body.replaceAll('\r\n', '\n'); + if (body.startsWith('> ')) { + final lines = body.split('\n'); + var i = 0; + while (i < lines.length && (lines[i].startsWith('> ') || lines[i].trimRight() == '>')) { + i++; + } + if (i < lines.length && lines[i].trim().isEmpty) i++; + if (i < lines.length) body = lines.skip(i).join(' ').trim(); + } + return (null, body.split('\n').first); + } +} + +class _ImageContent extends ConsumerStatefulWidget { + final Event event; + final PyramidTheme pt; + const _ImageContent({required this.event, required this.pt}); + + @override + ConsumerState<_ImageContent> createState() => _ImageContentState(); +} + +class _ImageContentState extends ConsumerState<_ImageContent> { + late Future<_MediaResult> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future<_MediaResult> _load() async { + final event = widget.event; + final rawUrl = event.content.tryGet('url') ?? ''; + if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) { + return _MediaResult.network(rawUrl); + } + // Serve from the shared LRU cache if this image was viewed recently. + final cached = MediaCache.instance.get(event.eventId); + if (cached != null) return _MediaResult.bytes(cached); + + final client = await ref.read(matrixClientProvider.future); + final mxcUri = event.attachmentMxcUrl; + if (mxcUri == null) return _MediaResult.none(); + if (event.isAttachmentEncrypted) { + try { + final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); + MediaCache.instance.put(event.eventId, file.bytes); + return _MediaResult.bytes(file.bytes); + } catch (e) { + return _MediaResult.error(_classifyMediaError(e)); + } + } else { + try { + final httpUri = await mxcUri.getThumbnailUri(client, width: 800, height: 600, method: ThumbnailMethod.scale); + final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); + if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); + MediaCache.instance.put(event.eventId, res.bodyBytes); + return _MediaResult.bytes(res.bodyBytes); + } catch (_) { + try { + final httpUri = await mxcUri.getDownloadUri(client); + final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); + if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); + MediaCache.instance.put(event.eventId, res.bodyBytes); + return _MediaResult.bytes(res.bodyBytes); + } catch (e) { + return _MediaResult.error(e.toString().split('\n').first); + } + } + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + + return LayoutBuilder(builder: (context, constraints) { + // Never exceed the available chat width (important on narrow phones), + // capped at the comfortable desktop maximum. + final maxW = constraints.maxWidth.isFinite + ? constraints.maxWidth.clamp(80.0, 420.0) + : 420.0; + const maxH = 320.0; + // Reserve the final display box up-front from the event's info dimensions, + // so the layout doesn't shift (and yank the scroll position) when the image + // finishes loading. Null when dimensions are absent. + final box = _reservedImageSize(widget.event, maxW: maxW, maxH: maxH); + + return FutureBuilder<_MediaResult>( + future: _future, + builder: (context, snap) { + Widget inner; + if (snap.connectionState == ConnectionState.waiting) { + inner = Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim))); + } else if (snap.hasData) { + final result = snap.data!; + if (result.bytes != null) { + inner = Image.memory( + result.bytes!, + fit: BoxFit.contain, + gaplessPlayback: true, + errorBuilder: (context, e, stack) => _mediaFallback(pt, e.toString().split('\n').first, widget.event.body), + ); + } else if (result.networkUrl != null) { + inner = Image.network(result.networkUrl!, fit: BoxFit.contain); + } else { + inner = _mediaFallback(pt, result.error, widget.event.body); + } + } else { + inner = _mediaFallback(pt, null, widget.event.body); + } + + final result = snap.data; + return GestureDetector( + onTap: (snap.hasData && result != null) + ? () => MediaViewer.show( + context, + senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(), + filename: widget.event.body, + bytes: result.bytes, + networkUrl: result.networkUrl, + event: widget.event, + ) + : null, + child: Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(top: 4, bottom: 4), + width: box?.width, + height: box?.height, + constraints: box == null + ? BoxConstraints( + minWidth: 120, minHeight: 90, + maxWidth: maxW, maxHeight: maxH, + ) + : null, + decoration: BoxDecoration( + color: pt.bg1, + borderRadius: BorderRadius.circular(pt.rBase), + ), + clipBehavior: Clip.hardEdge, + child: inner, + ), + ), + ); + }, + ); + }); + } +} + +// Computes the on-screen box for an image/video event, fitting within +// [maxW]×[maxH] while preserving the aspect ratio from the event's `info` +// (w/h). Used to reserve layout space before the media loads so nothing shifts. +// Returns null when the event carries no dimensions — callers should then use +// loose constraints instead of a fixed box. +Size? _reservedImageSize(Event event, {required double maxW, required double maxH}) { + final info = event.content.tryGetMap('info'); + final w = (info?['w'] as num?)?.toDouble(); + final h = (info?['h'] as num?)?.toDouble(); + if (w == null || h == null || w <= 0 || h <= 0) return null; + // Fit inside maxW×maxH without distorting; never upscale small images. + var scale = (maxW / w).clamp(0.0, maxH / h); + if (scale > 1.0) scale = 1.0; + return Size(w * scale, h * scale); +} + +// Downloads media bytes then hands off to PyramidAudioPlayer / PyramidVideoPlayer +class _AudioContent extends ConsumerStatefulWidget { + final Event event; + final PyramidTheme pt; + const _AudioContent({required this.event, required this.pt}); + + @override + ConsumerState<_AudioContent> createState() => _AudioContentState(); +} + +class _AudioContentState extends ConsumerState<_AudioContent> { + late Future<_MediaResult> _future; + + @override + void initState() { + super.initState(); + _future = _loadBytes(); + } + + Future<_MediaResult> _loadBytes() async { + final client = await ref.read(matrixClientProvider.future); + final event = widget.event; + if (event.isAttachmentEncrypted) { + try { + final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); + return _MediaResult.bytes(file.bytes); + } catch (e) { + return _MediaResult.error(_classifyMediaError(e)); + } + } + final mxcUri = event.attachmentMxcUrl; + if (mxcUri == null) return _MediaResult.none(); + try { + final httpUri = await mxcUri.getDownloadUri(client); + final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); + if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); + return _MediaResult.bytes(res.bodyBytes); + } catch (e) { + return _MediaResult.error(e.toString().split('\n').first); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return FutureBuilder<_MediaResult>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim)), + const SizedBox(width: 10), + Text('Lade Audio…', style: TextStyle(color: pt.fgMuted, fontSize: 12)), + ]), + ); + } + final result = snap.data; + if (result?.bytes == null) { + return _mediaFallback(pt, result?.error, widget.event.body); + } + return PyramidAudioPlayer( + bytes: result!.bytes!, + filename: widget.event.body, + pt: pt, + isVoice: widget.event.content['msgtype'] == 'm.audio', + ); + }, + ); + } +} + +class _VideoContent extends ConsumerStatefulWidget { + final Event event; + final PyramidTheme pt; + const _VideoContent({required this.event, required this.pt}); + + @override + ConsumerState<_VideoContent> createState() => _VideoContentState(); +} + +class _VideoContentState extends ConsumerState<_VideoContent> { + late Future<_MediaResult> _future; + + @override + void initState() { + super.initState(); + _future = _loadBytes(); + } + + Future<_MediaResult> _loadBytes() async { + final client = await ref.read(matrixClientProvider.future); + final event = widget.event; + if (event.isAttachmentEncrypted) { + try { + final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); + return _MediaResult.bytes(file.bytes); + } catch (e) { + return _MediaResult.error(_classifyMediaError(e)); + } + } + final mxcUri = event.attachmentMxcUrl; + if (mxcUri == null) return _MediaResult.none(); + try { + final httpUri = await mxcUri.getDownloadUri(client); + final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); + if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); + return _MediaResult.bytes(res.bodyBytes); + } catch (e) { + return _MediaResult.error(e.toString().split('\n').first); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return FutureBuilder<_MediaResult>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return Container( + margin: const EdgeInsets.only(top: 4, bottom: 4), + constraints: const BoxConstraints(maxWidth: 360), + height: 200, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), + ); + } + final result = snap.data; + if (result?.bytes == null) { + return _FileContent(event: widget.event, pt: pt, icon: Icons.videocam_rounded); + } + return PyramidVideoPlayer( + bytes: result!.bytes!, + filename: widget.event.body, + pt: pt, + senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(), + ); + }, + ); + } +} + +class _MediaContent extends ConsumerStatefulWidget { + final Event event; + final PyramidTheme pt; + + const _MediaContent({required this.event, required this.pt}); + + @override + ConsumerState<_MediaContent> createState() => _MediaContentState(); +} + +class _MediaContentState extends ConsumerState<_MediaContent> { + late Future<_MediaResult> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future<_MediaResult> _load() async { + final client = await ref.read(matrixClientProvider.future); + final event = widget.event; + + // External URL (e.g. Giphy GIFs) — not an MXC URI + final rawUrl = event.content.tryGet('url') ?? ''; + if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) { + return _MediaResult.network(rawUrl); + } + + final mxcUri = event.attachmentMxcUrl; + if (mxcUri == null) return _MediaResult.none(); + + if (event.isAttachmentEncrypted) { + try { + final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); + return _MediaResult.bytes(file.bytes); + } catch (e) { + return _MediaResult.error(_classifyMediaError(e)); + } + } else { + try { + final httpUri = await mxcUri.getThumbnailUri( + client, + width: 800, + height: 600, + method: ThumbnailMethod.scale, + ); + final res = await client.httpClient.get( + httpUri, + headers: {'authorization': 'Bearer ${client.accessToken}'}, + ); + if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); + return _MediaResult.bytes(res.bodyBytes); + } catch (_) { + // Fall back to full-size download + try { + final httpUri = await mxcUri.getDownloadUri(client); + final res = await client.httpClient.get( + httpUri, + headers: {'authorization': 'Bearer ${client.accessToken}'}, + ); + if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); + return _MediaResult.bytes(res.bodyBytes); + } catch (e) { + return _MediaResult.error(e.toString().split('\n').first); + } + } + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final isSticker = widget.event.type == EventTypes.Sticker; + final maxW = isSticker ? 160.0 : 420.0; + final maxH = isSticker ? 160.0 : 320.0; + + return FutureBuilder<_MediaResult>( + future: _future, + builder: (context, snap) { + Widget inner; + if (snap.connectionState == ConnectionState.waiting) { + inner = Center( + child: SizedBox( + width: 24, height: 24, + child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim), + ), + ); + } else if (snap.hasData) { + final result = snap.data!; + if (result.networkUrl != null) { + inner = Image.network( + result.networkUrl!, + fit: BoxFit.contain, + errorBuilder: (context, e, stack) => _fallback(pt), + ); + } else if (result.bytes != null) { + inner = Image.memory( + result.bytes!, + fit: BoxFit.contain, + errorBuilder: (context, e, stack) => _fallback(pt), + ); + } else { + inner = _fallback(pt, result.error); + } + } else { + inner = _fallback(pt); + } + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(top: 4, bottom: 4), + constraints: BoxConstraints(maxWidth: maxW, maxHeight: maxH), + decoration: isSticker ? null : BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + clipBehavior: isSticker ? Clip.none : Clip.hardEdge, + child: inner, + ), + ); + }, + ); + } + + Widget _fallback(PyramidTheme pt, [String? error]) => + _mediaFallback(pt, error, widget.event.body); +} + +// Downloads bytes for a PDF/SVG file and shows PdfInlinePreview or SvgImage. +class _DocPreviewContent extends ConsumerStatefulWidget { + final Event event; + final PyramidTheme pt; + const _DocPreviewContent({required this.event, required this.pt}); + + @override + ConsumerState<_DocPreviewContent> createState() => _DocPreviewContentState(); +} + +class _DocPreviewContentState extends ConsumerState<_DocPreviewContent> { + late Future<_MediaResult> _future; + + @override + void initState() { + super.initState(); + _future = _loadBytes(); + } + + Future<_MediaResult> _loadBytes() async { + final event = widget.event; + // Serve from cache if we already downloaded this attachment this session. + final cached = _DocBytesCache.get(event.eventId); + if (cached != null) return _MediaResult.bytes(cached); + + final client = await ref.read(matrixClientProvider.future); + if (event.isAttachmentEncrypted) { + try { + final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); + _DocBytesCache.put(event.eventId, file.bytes); + return _MediaResult.bytes(file.bytes); + } catch (e) { + return _MediaResult.error(_classifyMediaError(e)); + } + } + final mxcUri = event.attachmentMxcUrl; + if (mxcUri == null) return _MediaResult.none(); + try { + final httpUri = await mxcUri.getDownloadUri(client); + final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); + if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); + _DocBytesCache.put(event.eventId, res.bodyBytes); + return _MediaResult.bytes(res.bodyBytes); + } catch (e) { + return _MediaResult.error(e.toString().split('\n').first); + } + } + + Future _saveFile(Uint8List bytes) async { + try { + Directory? dir; + if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + dir = await getDownloadsDirectory(); + } + dir ??= await getTemporaryDirectory(); + final filename = widget.event.body.isNotEmpty ? widget.event.body : 'file'; + final ext = filename.contains('.') ? filename.split('.').last : 'bin'; + final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext'; + final file = File('${dir.path}/$name'); + await file.writeAsBytes(bytes); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Gespeichert: ${file.path}'), + duration: const Duration(seconds: 4), + )); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final ext = widget.event.body.contains('.') + ? widget.event.body.split('.').last.toLowerCase() + : ''; + + return FutureBuilder<_MediaResult>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return Container( + width: 280, + height: 80, + margin: const EdgeInsets.only(top: 4, bottom: 4), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), + ); + } + + final result = snap.data; + if (result?.bytes == null) { + return _FileContent(event: widget.event, pt: pt, icon: Icons.picture_as_pdf_outlined); + } + + final bytes = result!.bytes!; + final sender = widget.event.senderFromMemoryOrFallback.calcDisplayname(); + + if (ext == 'pdf' || ext == 'ai' || ext == 'eps') { + return PdfInlinePreview( + bytes: bytes, + filename: widget.event.body, + pt: pt, + senderName: sender, + onDownload: () => _saveFile(bytes), + ); + } + + // SVG — show inline like an image + return GestureDetector( + onTap: () => DocumentViewer.show( + context, + filename: widget.event.body, + bytes: bytes, + senderName: sender, + ), + child: Container( + margin: const EdgeInsets.only(top: 4, bottom: 4), + constraints: const BoxConstraints(maxWidth: 300, maxHeight: 300), + decoration: BoxDecoration( + color: pt.bg1, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + clipBehavior: Clip.hardEdge, + child: SvgPicture.memory(bytes, fit: BoxFit.contain), + ), + ); + }, + ); + } +} + +// Inline card for proprietary formats — shows immediately from event metadata, +// downloads lazily only when the user taps or hits the download button. +class _ProprietaryInlineCard extends ConsumerStatefulWidget { + final Event event; + final PyramidTheme pt; + const _ProprietaryInlineCard({required this.event, required this.pt}); + + @override + ConsumerState<_ProprietaryInlineCard> createState() => _ProprietaryInlineCardState(); +} + +class _ProprietaryInlineCardState extends ConsumerState<_ProprietaryInlineCard> { + bool _loading = false; + Uint8List? _cachedBytes; + + String get _filename => widget.event.body.isNotEmpty ? widget.event.body : 'file'; + String get _ext => _filename.contains('.') ? _filename.split('.').last.toLowerCase() : ''; + int? get _fileSize => widget.event.content.tryGetMap('info')?['size'] as int?; + + String _fmtSize(int b) { + if (b < 1024) return '$b B'; + if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB'; + return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + IconData get _icon => switch (_ext) { + 'psd' || 'psb' => Icons.photo_camera_outlined, + 'ai' || 'eps' => Icons.gesture_outlined, + 'indd' || 'inx' => Icons.menu_book_outlined, + 'afphoto' || 'afdesign' || 'afpub' => Icons.palette_outlined, + 'sketch' || 'fig' || 'xd' => Icons.design_services_outlined, + 'doc' => Icons.description_outlined, + 'ppt' => Icons.slideshow_outlined, + 'xls' => Icons.table_chart_outlined, + _ => Icons.insert_drive_file_outlined, + }; + + Future _download() async { + if (_cachedBytes != null) return _cachedBytes!; + final event = widget.event; + final shared = _DocBytesCache.get(event.eventId); + if (shared != null) return _cachedBytes = shared; + + final client = await ref.read(matrixClientProvider.future); + if (event.isAttachmentEncrypted) { + final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); + _DocBytesCache.put(event.eventId, file.bytes); + return _cachedBytes = file.bytes; + } + final mxcUri = event.attachmentMxcUrl; + if (mxcUri == null) throw Exception('Kein URI'); + final httpUri = await mxcUri.getDownloadUri(client); + final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); + if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}'); + _DocBytesCache.put(event.eventId, res.bodyBytes); + return _cachedBytes = res.bodyBytes; + } + + Future _openViewer() async { + if (_loading) return; + setState(() => _loading = true); + try { + final bytes = await _download(); + if (!mounted) return; + await DocumentViewer.show( + context, + filename: _filename, + bytes: bytes, + senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(), + ); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3))); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _save() async { + if (_loading) return; + setState(() => _loading = true); + try { + final bytes = await _download(); + Directory? dir; + if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + dir = await getDownloadsDirectory(); + } + dir ??= await getTemporaryDirectory(); + final outFile = File('${dir.path}/pyramid_${DateTime.now().millisecondsSinceEpoch}.$_ext'); + await outFile.writeAsBytes(bytes); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Gespeichert: ${outFile.path}'), duration: const Duration(seconds: 4))); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3))); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final extLabel = _ext.toUpperCase(); + final size = _fileSize; + + return GestureDetector( + onTap: _openViewer, + child: Container( + width: 280, + margin: const EdgeInsets.only(top: 4, bottom: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + clipBehavior: Clip.hardEdge, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Visual area + Container( + width: 280, + height: 140, + color: pt.accentSoft, + child: Stack( + alignment: Alignment.center, + children: [ + Icon(_icon, size: 56, color: pt.accent.withAlpha(180)), + Positioned( + bottom: 12, + right: 12, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: pt.accent, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + extLabel.length > 4 ? extLabel.substring(0, 4) : extLabel, + style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + ), + // Footer + Container( + color: pt.bg2, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + child: Row( + children: [ + Icon(_icon, size: 14, color: pt.fgDim), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(_filename, style: TextStyle(color: pt.fg, fontSize: 12, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis), + if (size != null) + Text(_fmtSize(size), style: TextStyle(color: pt.fgMuted, fontSize: 10)), + ], + ), + ), + if (_loading) + SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)) + else + GestureDetector( + onTap: _save, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon(Icons.download_rounded, size: 16, color: pt.fgMuted), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _FileContent extends ConsumerStatefulWidget { + final Event event; + final PyramidTheme pt; + final IconData icon; + const _FileContent({required this.event, required this.pt, required this.icon}); + + @override + ConsumerState<_FileContent> createState() => _FileContentState(); +} + +class _FileContentState extends ConsumerState<_FileContent> { + bool _saving = false; + bool _previewing = false; + + String get _filename => widget.event.body.isNotEmpty ? widget.event.body : 'file'; + + String _ext() { + final n = _filename; + return n.contains('.') ? n.split('.').last.toLowerCase() : 'bin'; + } + + Future _preview() async { + if (_previewing || _saving) return; + setState(() => _previewing = true); + try { + final client = await ref.read(matrixClientProvider.future); + final event = widget.event; + Uint8List bytes; + if (event.isAttachmentEncrypted) { + final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); + bytes = file.bytes; + } else { + final mxcUri = event.attachmentMxcUrl; + if (mxcUri == null) throw Exception('Kein URI'); + final httpUri = await mxcUri.getDownloadUri(client); + final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); + if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}'); + bytes = res.bodyBytes; + } + if (!mounted) return; + await DocumentViewer.show( + context, + filename: _filename, + bytes: bytes, + senderName: event.senderFromMemoryOrFallback.calcDisplayname(), + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)), + ); + } + } finally { + if (mounted) setState(() => _previewing = false); + } + } + + Future _save({bool openAfter = false}) async { + if (_saving) return; + setState(() => _saving = true); + try { + final client = await ref.read(matrixClientProvider.future); + final event = widget.event; + Uint8List bytes; + if (event.isAttachmentEncrypted) { + final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); + bytes = file.bytes; + } else { + final mxcUri = event.attachmentMxcUrl; + if (mxcUri == null) throw Exception('Kein URI'); + final httpUri = await mxcUri.getDownloadUri(client); + final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); + if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}'); + bytes = res.bodyBytes; + } + + Directory? dir; + if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + dir = await getDownloadsDirectory(); + } + dir ??= await getTemporaryDirectory(); + final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.${_ext()}'; + final outFile = File('${dir.path}/$name'); + await outFile.writeAsBytes(bytes); + + if (openAfter) { + if (Platform.isWindows) { + await Process.run('cmd', ['/c', 'start', '', outFile.path]); + } else if (Platform.isMacOS) { + await Process.run('open', [outFile.path]); + } else { + await Process.run('xdg-open', [outFile.path]); + } + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(openAfter ? 'Geöffnet: ${outFile.path}' : 'Gespeichert: ${outFile.path}'), + duration: const Duration(seconds: 4), + )); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final isVideo = widget.event.messageType == 'm.video'; + + return Container( + margin: const EdgeInsets.only(top: 4, bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + constraints: const BoxConstraints(maxWidth: 340), + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)), + child: Icon(widget.icon, size: 22, color: pt.accent), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(_filename, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), maxLines: 2, overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), + Text(_ext().toUpperCase(), style: TextStyle(color: pt.fgDim, fontSize: 11)), + ], + ), + ), + const SizedBox(width: 8), + if (_saving || _previewing) + SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)) + else + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (DocumentViewer.supportsPreview(_filename)) + IconButton( + onPressed: _preview, + tooltip: 'Vorschau', + icon: Icon(Icons.visibility_outlined, size: 22, color: pt.fgMuted), + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: EdgeInsets.zero, + ), + if (isVideo) + IconButton( + onPressed: () => _save(openAfter: true), + tooltip: 'Öffnen', + icon: Icon(Icons.play_circle_outline_rounded, size: 22, color: pt.fgMuted), + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: EdgeInsets.zero, + ), + IconButton( + onPressed: () => _save(), + tooltip: 'Herunterladen', + icon: Icon(Icons.download_rounded, size: 22, color: pt.fgMuted), + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: EdgeInsets.zero, + ), + ], + ), + ], + ), + ); + } +} + +String _classifyMediaError(Object e) { + final s = e.toString(); + if (s.contains('HandshakeException') || s.contains('CERTIFICATE') || s.contains('SocketException')) { + return 'Verbindungsfehler (TLS/Netzwerk)'; + } + if (s.contains('HTTP 4') || s.contains('HTTP 5') || s.contains('Unknown error when fetching')) { + return 'Datei nicht verfügbar (Serverfehler)'; + } + if (s.contains('decrypt') || s.contains('Decrypt') || s.contains('OlmException')) { + return 'Entschlüsselung fehlgeschlagen'; + } + return 'Download fehlgeschlagen'; +} + +// Thin wrapper over the shared MediaCache so document previews share the same +// LRU byte budget as chat images (keeps the last-viewed media instantly +// available, evicts the oldest). +class _DocBytesCache { + static Uint8List? get(String eventId) => MediaCache.instance.get(eventId); + static void put(String eventId, Uint8List bytes) => + MediaCache.instance.put(eventId, bytes); +} + +// Download callback that validates HTTP status before returning bytes. +// Prevents false "decryption failed" errors when the server returns an +// error page instead of the actual encrypted file bytes. +Future Function(Uri) _checkedDownload(Client client) { + return (Uri url) async { + final res = await client.httpClient.get( + url, + headers: {'authorization': 'Bearer ${client.accessToken}'}, + ); + if (res.statusCode != 200) { + throw Exception('HTTP ${res.statusCode}: ${res.body}'); + } + return res.bodyBytes; + }; +} + +Widget _mediaFallback(PyramidTheme pt, String? error, String body) => Padding( + padding: const EdgeInsets.all(12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.broken_image_outlined, size: 16, color: pt.fgDim), + const SizedBox(width: 6), + Flexible(child: Text(error ?? body, style: TextStyle(color: pt.fgDim, fontSize: 12), overflow: TextOverflow.ellipsis)), + ], + ), +); + +class _MediaResult { + final String? networkUrl; + final Uint8List? bytes; + final String? error; + + _MediaResult._({this.networkUrl, this.bytes, this.error}); + factory _MediaResult.network(String url) => _MediaResult._(networkUrl: url); + factory _MediaResult.bytes(Uint8List b) => _MediaResult._(bytes: b); + factory _MediaResult.error(String e) => _MediaResult._(error: e); + factory _MediaResult.none() => _MediaResult._(); +} + +// ── Reactions row ───────────────────────────────────────────────────────── + +class _ReactionsRow extends StatelessWidget { + final Event event; + final Timeline timeline; + final Room? room; + final String currentUserId; + final PyramidTheme pt; + + const _ReactionsRow({ + required this.event, + required this.timeline, + required this.currentUserId, + required this.pt, + this.room, + }); + + @override + Widget build(BuildContext context) { + final reactionEvents = event.aggregatedEvents(timeline, RelationshipTypes.reaction); + if (reactionEvents.isEmpty) return const SizedBox.shrink(); + + // Group by emoji key → list of senders + final Map> grouped = {}; + for (final r in reactionEvents) { + final key = r.content.tryGetMap('m.relates_to')?['key'] as String?; + if (key == null || key.isEmpty) continue; + (grouped[key] ??= []).add(r.senderId); + } + if (grouped.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 2), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: grouped.entries.map((e) { + final myReaction = e.value.contains(currentUserId); + return _ReactionChip( + emoji: e.key, + count: e.value.length, + mine: myReaction, + pt: pt, + onTap: () { + if (myReaction) { + // Find own reaction event to redact + for (final r in reactionEvents) { + final key = r.content.tryGetMap('m.relates_to')?['key'] as String?; + if (key == e.key && r.senderId == currentUserId) { + r.redactEvent().catchError((_) => null); + break; + } + } + } else { + room?.sendReaction(event.eventId, e.key).catchError((_) => ''); + ReactionService.recordUsed(e.key); + } + }, + ); + }).toList(), + ), + ); + } +} + +class _ReactionChip extends StatefulWidget { + final String emoji; + final int count; + final bool mine; + final PyramidTheme pt; + final VoidCallback onTap; + + const _ReactionChip({ + required this.emoji, + required this.count, + required this.mine, + required this.pt, + required this.onTap, + }); + + @override + State<_ReactionChip> createState() => _ReactionChipState(); +} + +class _ReactionChipState extends State<_ReactionChip> { + 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: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: widget.mine + ? pt.accentSoft + : (_hovered ? pt.bgHover : pt.bg2), + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all( + color: widget.mine ? pt.accent : (_hovered ? pt.borderStrong : pt.border), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(widget.emoji, style: const TextStyle(fontSize: 13)), + const SizedBox(width: 4), + Text( + '${widget.count}', + style: TextStyle( + color: widget.mine ? pt.accent : pt.fgMuted, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _Avatar extends ConsumerStatefulWidget { + final String initial; + final Color color; + final PyramidTheme pt; + final double size; + final Uri? avatarUri; + + const _Avatar({ + required this.initial, + required this.color, + required this.pt, + required this.size, + this.avatarUri, + }); + + @override + ConsumerState<_Avatar> createState() => _AvatarState(); +} + +class _AvatarState extends ConsumerState<_Avatar> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final client = ref.watch(matrixClientProvider).valueOrNull; + final radius = BorderRadius.circular(widget.size / 2); + + Widget child; + if (client != null && widget.avatarUri != null) { + child = MxcAvatar( + mxcUri: widget.avatarUri, + client: client, + size: widget.size, + borderRadius: radius, + placeholder: (_) => _InitialBox( + initial: widget.initial, + color: widget.color, + size: widget.size, + radius: radius, + ), + ); + } else { + child = _InitialBox( + initial: widget.initial, + color: widget.color, + size: widget.size, + radius: radius, + ); + } + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutBack, + transform: _hovered + ? Matrix4.diagonal3Values(1.06, 1.06, 1.0) + : Matrix4.identity(), + transformAlignment: Alignment.center, + width: widget.size, + height: widget.size, + child: child, + ), + ); + } +} + +class _InitialBox extends StatelessWidget { + final String initial; + final Color color; + final double size; + final BorderRadius radius; + const _InitialBox({required this.initial, required this.color, required this.size, required this.radius}); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration(color: color, borderRadius: radius), + child: Center( + child: Text(initial, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)), + ), + ); + } +} + +class _HoverActions extends StatefulWidget { + final PyramidTheme pt; + final Event event; + final List events; + final Room? room; + final String currentUserId; + final VoidCallback? onReply; + final ValueChanged? onOverlayStateChanged; + + const _HoverActions({ + required this.pt, + required this.event, + required this.events, + required this.currentUserId, + this.room, + this.onReply, + this.onOverlayStateChanged, + }); + + @override + State<_HoverActions> createState() => _HoverActionsState(); +} + +class _HoverActionsState extends State<_HoverActions> { + OverlayEntry? _reactionOverlay; + List _quickReactions = ReactionService.quickReactions; + + @override + void initState() { + super.initState(); + ReactionService.load().then((_) { + if (mounted) setState(() => _quickReactions = ReactionService.quickReactions); + }); + } + + void _closeReactionOverlay() { + _reactionOverlay?.remove(); + _reactionOverlay = null; + widget.onOverlayStateChanged?.call(false); + } + + void _toggleReactionPicker() { + if (_reactionOverlay != null) { + _closeReactionOverlay(); + return; + } + + final box = context.findRenderObject() as RenderBox?; + final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero; + + _reactionOverlay = OverlayEntry(builder: (ctx) { + final screenSize = MediaQuery.sizeOf(ctx); + const pickerW = 340.0; + const pickerH = 380.0; + double left = (offset.dx - pickerW / 2).clamp(8.0, screenSize.width - pickerW - 8); + double top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8); + + return Material( + type: MaterialType.transparency, + child: Stack(children: [ + GestureDetector( + onTap: _closeReactionOverlay, + behavior: HitTestBehavior.translucent, + child: const SizedBox.expand(), + ), + Positioned( + left: left, + top: top, + child: EmojiPicker( + onEmojiSelected: (emoji) { + _closeReactionOverlay(); + widget.room?.sendReaction(widget.event.eventId, emoji) + .catchError((_) => ''); + ReactionService.recordUsed(emoji).then((_) { + if (mounted) setState(() => _quickReactions = ReactionService.quickReactions); + }); + }, + onClose: _closeReactionOverlay, + ), + ), + ]), + ); + }); + widget.onOverlayStateChanged?.call(true); + Overlay.of(context).insert(_reactionOverlay!); + } + + void _showMoreMenu() { + final box = context.findRenderObject() as RenderBox?; + final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero; + final size = box?.size ?? Size.zero; + + final isOwnMessage = widget.event.senderId == widget.currentUserId; + final text = widget.event.body; + + // Find any image or sticker event in the group that can be saved as a sticker. + final imageEvent = widget.events.firstWhereOrNull( + (e) => e.messageType == 'm.image' || e.type == EventTypes.Sticker, + ); + + // Capture messenger synchronously before showMenu's async gap so the + // snackbar can be shown even if the widget is unmounted by then. + final messenger = ScaffoldMessenger.maybeOf(context); + + showMenu( + context: context, + position: RelativeRect.fromLTRB( + offset.dx, offset.dy - size.height, + offset.dx + size.width, offset.dy, + ), + items: [ + PopupMenuItem(value: 'copy', child: Row(children: [ + Icon(Icons.copy_rounded, size: 16, color: widget.pt.fgMuted), + const SizedBox(width: 10), + Text('Kopieren', style: TextStyle(color: widget.pt.fg, fontSize: 14)), + ])), + if (imageEvent != null) + PopupMenuItem(value: 'save_sticker', child: Row(children: [ + Icon(Icons.sticky_note_2_outlined, size: 16, color: widget.pt.fgMuted), + const SizedBox(width: 10), + Text('Als Sticker speichern', style: TextStyle(color: widget.pt.fg, fontSize: 14)), + ])), + if (isOwnMessage) + PopupMenuItem(value: 'delete', child: Row(children: [ + Icon(Icons.delete_outline_rounded, size: 16, color: widget.pt.danger), + const SizedBox(width: 10), + Text('Löschen', style: TextStyle(color: widget.pt.danger, fontSize: 14)), + ])), + PopupMenuItem(value: 'pin', child: Row(children: [ + Icon(Icons.push_pin_outlined, size: 16, color: widget.pt.fgMuted), + const SizedBox(width: 10), + Text('Anpinnen', style: TextStyle(color: widget.pt.fg, fontSize: 14)), + ])), + ], + ).then((value) async { + if (value == 'copy') { + Clipboard.setData(ClipboardData(text: text)); + } else if (value == 'save_sticker' && imageEvent != null) { + final ok = await GifFavoriteService.addImageAsStickerFavorite(imageEvent); + messenger?.showSnackBar(SnackBar( + content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'), + duration: const Duration(seconds: 2), + )); + } else if (value == 'delete') { + widget.event.redactEvent(reason: 'Vom Nutzer gelöscht') + .catchError((_) => null); + } else if (value == 'pin') { + final room = widget.room; + if (room == null) return; + final pinned = List.from( + room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [], + ); + final eventId = widget.event.eventId; + if (!pinned.contains(eventId)) pinned.add(eventId); + room.setPinnedEvents(pinned).catchError((_) => ''); + } + }); + } + + @override + void dispose() { + if (_reactionOverlay != null) { + _reactionOverlay!.remove(); + _reactionOverlay = null; + widget.onOverlayStateChanged?.call(false); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: pt.bg0, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(100), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + // Quick reactions + ..._quickReactions.map((emoji) => _QuickReactionBtn( + emoji: emoji, + pt: pt, + onTap: () { + widget.room?.sendReaction(widget.event.eventId, emoji) + .catchError((_) => ''); + ReactionService.recordUsed(emoji).then((_) { + if (mounted) setState(() => _quickReactions = ReactionService.quickReactions); + }); + }, + )), + Container(width: 1, height: 18, color: pt.border, margin: const EdgeInsets.symmetric(horizontal: 2)), + _ActionBtn( + icon: Icons.add_reaction_outlined, + tooltip: 'Reaktion', + pt: pt, + accent: true, + onTap: _toggleReactionPicker, + ), + _ActionBtn( + icon: Icons.reply_rounded, + tooltip: 'Antworten', + pt: pt, + onTap: widget.onReply, + ), + _ActionBtn( + icon: Icons.more_horiz_rounded, + tooltip: 'Mehr', + pt: pt, + onTap: _showMoreMenu, + ), + ], + ), + ); + } +} + +class _QuickReactionBtn extends StatefulWidget { + final String emoji; + final PyramidTheme pt; + final VoidCallback? onTap; + + const _QuickReactionBtn({required this.emoji, required this.pt, this.onTap}); + + @override + State<_QuickReactionBtn> createState() => _QuickReactionBtnState(); +} + +class _QuickReactionBtnState extends State<_QuickReactionBtn> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + width: 28, + height: 28, + decoration: BoxDecoration( + color: _hovered ? widget.pt.bgHover : Colors.transparent, + borderRadius: BorderRadius.circular(widget.pt.rSm), + ), + child: Center( + child: Text(widget.emoji, style: const TextStyle(fontSize: 15)), + ), + ), + ), + ); + } +} + +class _ActionBtn extends StatefulWidget { + final IconData icon; + final String tooltip; + final PyramidTheme pt; + final bool accent; + final VoidCallback? onTap; + + const _ActionBtn({ + required this.icon, + required this.tooltip, + required this.pt, + this.accent = false, + this.onTap, + }); + + @override + State<_ActionBtn> createState() => _ActionBtnState(); +} + +class _ActionBtnState extends State<_ActionBtn> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final color = _hovered + ? (widget.accent ? widget.pt.accent : widget.pt.fg) + : widget.pt.fgMuted; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: Tooltip( + message: widget.tooltip, + waitDuration: const Duration(milliseconds: 400), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 32, + height: 32, + decoration: BoxDecoration( + color: _hovered ? widget.pt.bgHover : Colors.transparent, + borderRadius: BorderRadius.circular(widget.pt.rSm), + ), + child: Center( + child: Icon(widget.icon, size: 16, color: color), + ), + ), + ), + ), + ); + } +} + +// Inline "Neue Nachrichten" divider rendered inside a MessageGroup when the +// fullyRead event is in the middle of the group (same sender, <5 min window). +class _InlineUnreadDivider extends StatelessWidget { + final PyramidTheme pt; + const _InlineUnreadDivider({required this.pt}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 4), + child: Row( + children: [ + Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + 'Neue Nachrichten', + style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600), + ), + ), + Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)), + ], + ), + ); + } +} + +// Date divider between message groups +class DateDivider extends StatelessWidget { + final DateTime date; + + const DateDivider({super.key, required this.date}); + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Row( + children: [ + Expanded(child: Divider(color: pt.border, height: 1)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + _formatDate(date), + style: TextStyle( + color: pt.fgDim, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.06 * 11, + ), + ), + ), + Expanded(child: Divider(color: pt.border, height: 1)), + ], + ), + ); + } + + String _formatDate(DateTime d) { + final now = DateTime.now(); + if (d.year == now.year && d.month == now.month && d.day == now.day) { + return 'Heute · ${d.day}. ${_monthName(d.month)}'; + } + final yesterday = now.subtract(const Duration(days: 1)); + if (d.year == yesterday.year && d.month == yesterday.month && d.day == yesterday.day) { + return 'Gestern'; + } + return '${d.day}. ${_monthName(d.month)} ${d.year}'; + } + + String _monthName(int m) => const [ + '', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember' + ][m]; +} + +class _MessageStatus extends StatelessWidget { + final Event lastEvent; + final PyramidTheme pt; + + const _MessageStatus({required this.lastEvent, required this.pt}); + + @override + Widget build(BuildContext context) { + final status = lastEvent.status; + if (status == EventStatus.error) { + return Icon(Icons.error_outline_rounded, size: 12, color: pt.danger); + } + if (status == EventStatus.sending) { + return Icon(Icons.access_time_rounded, size: 12, color: pt.fgDim); + } + // Sent (delivered). "Read" is shown by the per-event ✓✓ marker. + return Icon(Icons.done_rounded, size: 13, color: pt.fgDim); + } +} + +// Read receipt: a ✓✓ shown at the partner's last-read message (any sender), +// so it moves next to their own message when they reply. +class _ReadMarker extends StatelessWidget { + final PyramidTheme pt; + const _ReadMarker({required this.pt}); + + // Goldener Doppelhaken — bewusst nicht die Akzentfarbe, damit "gelesen" + // unabhängig vom Theme sofort erkennbar ist. + static const _gold = Color(0xFFE6B422); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only(right: 24, top: 1, bottom: 1), + child: Align( + alignment: Alignment.centerRight, + child: Icon(Icons.done_all_rounded, size: 13, color: _gold), + ), + ); + } +} + + +// ─── Matrix HTML renderer ────────────────────────────────────────────────────── +// +// Handles the subset of HTML that Matrix clients emit in formatted_body: +// / → bold +// / → italic +// / → strikethrough +// → underline +// → monospace, slightly different background +// → tappable link (url_launcher) +//
→ newline +// HTML entities → decoded +// Anything else → text content preserved, tags stripped +// +// Note:
 and 
are simplified (rendered as indented code/text). + +class _MatrixHtmlText extends StatelessWidget { + final String html; + final TextStyle baseStyle; + final Color linkColor; + + const _MatrixHtmlText({ + required this.html, + required this.baseStyle, + required this.linkColor, + }); + + @override + Widget build(BuildContext context) { + final spans = _parseHtml(html, baseStyle, linkColor); + return SelectionArea( + child: RichText(text: TextSpan(children: spans, style: baseStyle)), + ); + } + + List _parseHtml(String html, TextStyle base, Color linkColor) { + // Normalise line endings and collapse whitespace around block elements. + var src = html + .replaceAll('\r\n', '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'<(p|div|li)[^>]*>', caseSensitive: false), '') + .replaceAll(RegExp(r']*>', caseSensitive: false), ' ') + .replaceAll(RegExp(r']*>', caseSensitive: false), ''); + + final spans = []; + + // Simple recursive stack-based parser. + // We tokenise into (tag | text) chunks, then apply style based on open tags. + final tagRe = RegExp(r'<(/?)(\w+)([^>]*)>', caseSensitive: false); + + var bold = 0; + var italic = 0; + var strike = 0; + var underline = 0; + var code = 0; + String? pendingHref; + + int pos = 0; + for (final m in tagRe.allMatches(src)) { + // Text before this tag. + if (m.start > pos) { + final text = _decodeEntities(src.substring(pos, m.start)); + if (text.isNotEmpty) { + spans.add(_buildSpan( + text, base, bold > 0, italic > 0, strike > 0, underline > 0, + code > 0, pendingHref, linkColor, + )); + if (pendingHref != null) pendingHref = null; + } + } + pos = m.end; + + final closing = m.group(1) == '/'; + final tag = m.group(2)!.toLowerCase(); + final attrs = m.group(3) ?? ''; + + if (closing) { + switch (tag) { + case 'b': case 'strong': bold = (bold - 1).clamp(0, 99); break; + case 'i': case 'em': italic = (italic - 1).clamp(0, 99); break; + case 'del': case 's': case 'strike': strike = (strike - 1).clamp(0, 99); break; + case 'u': underline = (underline - 1).clamp(0, 99); break; + case 'code': code = (code - 1).clamp(0, 99); break; + case 'a': pendingHref = null; break; + } + } else { + switch (tag) { + case 'b': case 'strong': bold++; break; + case 'i': case 'em': italic++; break; + case 'del': case 's': case 'strike': strike++; break; + case 'u': underline++; break; + case 'code': code++; break; + case 'a': + final hrefMatch = RegExp("""href=["']([^"']+)["']""", caseSensitive: false) + .firstMatch(attrs); + pendingHref = hrefMatch?.group(1); + break; + } + } + } + + // Remaining text after last tag. + if (pos < src.length) { + final text = _decodeEntities(src.substring(pos)); + if (text.isNotEmpty) { + spans.add(_buildSpan( + text, base, bold > 0, italic > 0, strike > 0, underline > 0, + code > 0, pendingHref, linkColor, + )); + } + } + + return spans; + } + + InlineSpan _buildSpan( + String text, + TextStyle base, + bool bold, bool italic, bool strike, bool underline, bool code, + String? href, Color linkColor, + ) { + var style = base.copyWith( + fontWeight: bold ? FontWeight.bold : null, + fontStyle: italic ? FontStyle.italic : null, + decoration: TextDecoration.combine([ + if (strike) TextDecoration.lineThrough, + if (underline) TextDecoration.underline, + ]), + fontFamily: code ? 'monospace' : null, + fontSize: code ? (base.fontSize ?? 14) * 0.9 : null, + ); + + if (href != null) { + style = style.copyWith( + color: linkColor, + decoration: TextDecoration.underline, + decorationColor: linkColor, + ); + return TextSpan( + text: text, + style: style, + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl(Uri.parse(href), mode: LaunchMode.externalApplication), + ); + } + return TextSpan(text: text, style: style); + } + + static String _decodeEntities(String s) => s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll(' ', ' '); +} + +// ─── Plain text with clickable URLs ─────────────────────────────────────────── + +class _PlainLinkText extends StatelessWidget { + final String text; + final TextStyle baseStyle; + final Color linkColor; + + const _PlainLinkText({ + required this.text, + required this.baseStyle, + required this.linkColor, + }); + + static final _urlRe = RegExp( + r'https?://[^\s\]\)>]+', + caseSensitive: false, + ); + + @override + Widget build(BuildContext context) { + final matches = _urlRe.allMatches(text).toList(); + if (matches.isEmpty) { + return SelectableText(text, style: baseStyle); + } + + final spans = []; + int pos = 0; + for (final m in matches) { + if (m.start > pos) { + spans.add(TextSpan(text: text.substring(pos, m.start))); + } + final url = m.group(0)!; + spans.add(TextSpan( + text: url, + style: baseStyle.copyWith( + color: linkColor, + decoration: TextDecoration.underline, + decorationColor: linkColor, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication), + )); + pos = m.end; + } + if (pos < text.length) { + spans.add(TextSpan(text: text.substring(pos))); + } + + return SelectionArea( + child: RichText(text: TextSpan(children: spans, style: baseStyle)), + ); + } +} diff --git a/lib/features/chat/pinned_messages_panel.dart b/lib/features/chat/pinned_messages_panel.dart new file mode 100644 index 0000000..dbbe16c --- /dev/null +++ b/lib/features/chat/pinned_messages_panel.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/features/chat/chat_provider.dart'; + +class PinnedMessagesPanel extends ConsumerWidget { + final String roomId; + final VoidCallback onClose; + + const PinnedMessagesPanel({ + super.key, + required this.roomId, + required this.onClose, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pt = PyramidTheme.of(context); + final room = ref.watch(roomProvider(roomId)); + final timeline = ref.watch(timelineProvider(roomId)).valueOrNull; + + if (room == null) return const SizedBox.shrink(); + + final pinnedIds = List.from( + room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [], + ); + + final pinned = pinnedIds + .map((id) => timeline?.events.where((e) => e.eventId == id).firstOrNull) + .where((e) => e != null) + .cast() + .toList(); + + return Container( + width: 320, + decoration: BoxDecoration( + color: pt.bg1, + border: Border(left: BorderSide(color: pt.border)), + ), + child: Column( + children: [ + // Header + Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: pt.border)), + ), + child: Row( + children: [ + Icon(Icons.push_pin_rounded, size: 16, color: pt.fgMuted), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Angepinnte Nachrichten', + style: TextStyle( + color: pt.fg, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + GestureDetector( + onTap: onClose, + child: Icon(Icons.close_rounded, size: 18, color: pt.fgDim), + ), + ], + ), + ), + // List + Expanded( + child: pinnedIds.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.push_pin_outlined, size: 40, color: pt.fgDim), + const SizedBox(height: 12), + Text( + 'Keine angepinnten Nachrichten', + style: TextStyle(color: pt.fgMuted, fontSize: 14), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: pinned.isEmpty ? pinnedIds.length : pinned.length, + itemBuilder: (ctx, i) { + if (pinned.isEmpty) { + return _PinnedPlaceholder(pt: pt); + } + return _PinnedMessageCard( + event: pinned[i], + room: room, + pt: pt, + onUnpin: () => _unpin(room, pinned[i].eventId), + ); + }, + ), + ), + ], + ), + ); + } + + void _unpin(Room room, String eventId) { + final pinned = List.from( + room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [], + ); + pinned.remove(eventId); + room.setPinnedEvents(pinned).catchError((_) => ''); + } +} + +class _PinnedPlaceholder extends StatelessWidget { + final PyramidTheme pt; + const _PinnedPlaceholder({required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + child: Text( + 'Nachricht wird geladen…', + style: TextStyle(color: pt.fgDim, fontSize: 13, fontStyle: FontStyle.italic), + ), + ); + } +} + +class _PinnedMessageCard extends StatelessWidget { + final Event event; + final Room room; + final PyramidTheme pt; + final VoidCallback onUnpin; + + const _PinnedMessageCard({ + required this.event, + required this.room, + required this.pt, + required this.onUnpin, + }); + + @override + Widget build(BuildContext context) { + final sender = event.senderFromMemoryOrFallback.calcDisplayname(); + final time = _formatTime(event.originServerTs); + final body = event.body; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.push_pin_rounded, size: 12, color: pt.accent), + const SizedBox(width: 6), + Expanded( + child: Text( + sender, + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + Text(time, style: TextStyle(color: pt.fgDim, fontSize: 11)), + const SizedBox(width: 6), + GestureDetector( + onTap: onUnpin, + child: Tooltip( + message: 'Entpinnen', + child: Icon(Icons.push_pin_outlined, size: 14, color: pt.fgDim), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + body, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + maxLines: 5, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } + + String _formatTime(DateTime t) { + final now = DateTime.now(); + final diff = now.difference(t); + if (diff.inDays == 0) { + return '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + } + if (diff.inDays < 7) return 'vor ${diff.inDays}d'; + return '${t.day}.${t.month}.${t.year}'; + } +} diff --git a/lib/features/chat/reaction_service.dart b/lib/features/chat/reaction_service.dart new file mode 100644 index 0000000..7fb6a03 --- /dev/null +++ b/lib/features/chat/reaction_service.dart @@ -0,0 +1,38 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class ReactionService { + static const _key = 'recent_reactions'; + static const _maxRecent = 6; + + static final _defaultReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏']; + + static List _recent = []; + static bool _loaded = false; + + static Future load() async { + if (_loaded) return; + final prefs = await SharedPreferences.getInstance(); + _recent = prefs.getStringList(_key) ?? []; + _loaded = true; + } + + static List get quickReactions { + if (_recent.isEmpty) return _defaultReactions; + // Fill up to 6 with defaults not already in recent + final result = List.from(_recent); + for (final d in _defaultReactions) { + if (result.length >= _maxRecent) break; + if (!result.contains(d)) result.add(d); + } + return result.take(_maxRecent).toList(); + } + + static Future recordUsed(String emoji) async { + await load(); + _recent.remove(emoji); + _recent.insert(0, emoji); + if (_recent.length > _maxRecent) _recent = _recent.sublist(0, _maxRecent); + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_key, _recent); + } +} diff --git a/lib/features/members/members_panel.dart b/lib/features/members/members_panel.dart new file mode 100644 index 0000000..872f48f --- /dev/null +++ b/lib/features/members/members_panel.dart @@ -0,0 +1,263 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/widgets/pyramid_logo.dart'; + +class MembersPanel extends ConsumerWidget { + final String roomId; + /// When set, a close button is shown in the header (used on mobile/overlay). + final VoidCallback? onClose; + /// Panel width. Defaults to 280 (desktop side panel). + final double width; + const MembersPanel({super.key, required this.roomId, this.onClose, this.width = 280}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pt = PyramidTheme.of(context); + final client = ref.watch(matrixClientProvider).valueOrNull; + final room = client?.getRoomById(roomId); + final members = room?.getParticipants() ?? []; + + // Show all members; presence info may not be available + final online = members.toList(); + final offline = []; + + return Container( + width: width, + decoration: BoxDecoration( + color: pt.bg1, + border: Border(left: BorderSide(color: pt.border)), + ), + child: Column( + children: [ + Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: pt.border)), + ), + child: Row( + children: [ + Icon(Icons.group_rounded, size: 16, color: pt.fg), + const SizedBox(width: 8), + Text( + 'Members', + style: TextStyle( + color: pt.fg, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 6), + Text( + '${members.length}', + style: TextStyle(color: pt.fgDim, fontSize: 14), + ), + if (onClose != null) ...[ + const Spacer(), + GestureDetector( + onTap: onClose, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon(Icons.close_rounded, size: 20, color: pt.fgMuted), + ), + ), + ], + ], + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.all(8), + children: [ + if (online.isNotEmpty) ...[ + _SectionLabel('Online — ${online.length}', pt), + ...online.map((m) => _MemberItem(member: m, pt: pt)), + ], + if (offline.isNotEmpty) ...[ + const SizedBox(height: 14), + _SectionLabel('Offline — ${offline.length}', pt), + ...offline.map((m) => _MemberItem( + member: m, + pt: pt, + isOffline: true, + )), + ], + if (members.isEmpty) ...[ + // Show placeholder members when room has none loaded + _SectionLabel('Online — 0', pt), + _SectionLabel('', pt), + Center( + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: Text( + 'No members loaded', + style: TextStyle(color: pt.fgDim, fontSize: 12), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + final String text; + final PyramidTheme pt; + + const _SectionLabel(this.text, this.pt); + + @override + Widget build(BuildContext context) { + if (text.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + child: Text( + text.toUpperCase(), + style: TextStyle( + color: pt.fgDim, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.04 * 11, + ), + ), + ); + } +} + +class _MemberItem extends StatefulWidget { + final User member; + final PyramidTheme pt; + final bool isOffline; + + const _MemberItem({ + required this.member, + required this.pt, + this.isOffline = false, + }); + + @override + State<_MemberItem> createState() => _MemberItemState(); +} + +class _MemberItemState extends State<_MemberItem> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final member = widget.member; + final name = member.calcDisplayname(); + final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; + final color = _colorFromName(name); + final status = widget.isOffline ? 'offline' : _memberStatus(member); + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + decoration: BoxDecoration( + color: _hovered ? pt.bgHover : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + children: [ + Opacity( + opacity: widget.isOffline ? 0.5 : 1, + child: Stack( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Center( + child: Text( + initial, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + if (!widget.isOffline) + Positioned( + bottom: -2, + right: -2, + child: PresenceDot( + status: status, + size: 9, + borderColor: pt.bgHover, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + color: widget.isOffline ? pt.fgMuted : pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _memberStatus(User m) { + try { + // ignore: deprecated_member_use + final p = m.room.client.presences[m.id]; + if (p == null) return 'offline'; + // Veraltete "online"-Presence vom Server nicht blind übernehmen: + // nur online zeigen, wenn die letzte Aktivität frisch ist. + final last = p.lastActiveTimestamp; + final fresh = last != null && + DateTime.now().difference(last) < const Duration(minutes: 5); + return switch (p.presence) { + PresenceType.online when p.currentlyActive == true || fresh => 'online', + PresenceType.online => 'offline', + PresenceType.unavailable => 'away', + _ => 'offline', + }; + } catch (_) { + return 'offline'; + } + } + + Color _colorFromName(String name) { + const colors = [ + Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981), + Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B), + ]; + if (name.isEmpty) return colors[0]; + return colors[name.codeUnitAt(0) % colors.length]; + } +} diff --git a/lib/features/rooms/room_list_item.dart b/lib/features/rooms/room_list_item.dart deleted file mode 100644 index 7ae29a9..0000000 --- a/lib/features/rooms/room_list_item.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:matrix/matrix.dart'; -import 'package:pyramid/widgets/mxc_image.dart'; - -class RoomListItem extends StatelessWidget { - final Room room; - final VoidCallback onTap; - - const RoomListItem({super.key, required this.room, required this.onTap}); - - @override - Widget build(BuildContext context) { - final unread = room.notificationCount; - final lastEvent = room.lastEvent; - final theme = Theme.of(context); - - return ListTile( - onTap: onTap, - leading: _RoomAvatar(room: room), - title: Text( - room.getLocalizedDisplayname(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: unread > 0 - ? theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold) - : theme.textTheme.bodyLarge, - ), - subtitle: lastEvent != null - ? Text( - _previewText(lastEvent), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ) - : null, - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (lastEvent != null) - Text( - _formatTime(lastEvent.originServerTs), - style: theme.textTheme.labelSmall?.copyWith( - color: unread > 0 - ? theme.colorScheme.primary - : theme.colorScheme.onSurfaceVariant, - ), - ), - if (unread > 0) ...[ - const SizedBox(height: 4), - _UnreadBadge(count: unread), - ], - ], - ), - ); - } - - String _previewText(Event event) { - if (event.type == EventTypes.Message) { - final body = event.body; - if (body.isNotEmpty) return body; - } - return event.type; - } - - String _formatTime(DateTime time) { - final now = DateTime.now(); - if (time.day == now.day && - time.month == now.month && - time.year == now.year) { - return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; - } - return '${time.day.toString().padLeft(2, '0')}.${time.month.toString().padLeft(2, '0')}'; - } -} - -class _RoomAvatar extends StatelessWidget { - final Room room; - const _RoomAvatar({required this.room}); - - @override - Widget build(BuildContext context) { - final avatar = room.avatar; - final name = room.getLocalizedDisplayname(); - final initials = name.isNotEmpty ? name[0].toUpperCase() : '?'; - final color = _colorForName(name, Theme.of(context).colorScheme); - - return CircleAvatar( - radius: 24, - backgroundColor: color, - child: ClipOval( - child: avatar != null - ? MxcImage( - mxcUri: avatar, - width: 48, - height: 48, - placeholder: (_) => Text( - initials, - style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.bold), - ), - ) - : Text( - initials, - style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.bold), - ), - ), - ); - } - - Color _colorForName(String name, ColorScheme scheme) { - final colors = [ - scheme.primary, - scheme.secondary, - scheme.tertiary, - Colors.teal, - Colors.indigo, - Colors.orange, - ]; - final hash = name.codeUnits.fold(0, (a, b) => a + b); - return colors[hash % colors.length]; - } -} - -class _UnreadBadge extends StatelessWidget { - final int count; - const _UnreadBadge({required this.count}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: theme.colorScheme.primary, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - count > 99 ? '99+' : '$count', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onPrimary, - fontWeight: FontWeight.bold, - ), - ), - ); - } -} diff --git a/lib/features/rooms/rooms_page.dart b/lib/features/rooms/rooms_page.dart deleted file mode 100644 index 399ab95..0000000 --- a/lib/features/rooms/rooms_page.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:pyramid/features/rooms/room_list_item.dart'; -import 'package:pyramid/features/rooms/rooms_provider.dart'; - -class RoomsPage extends ConsumerWidget { - const RoomsPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final roomsAsync = ref.watch(roomListProvider); - - return Scaffold( - appBar: AppBar( - title: const Text('Pyramid'), - actions: [ - IconButton( - icon: const Icon(Icons.settings_outlined), - onPressed: () => context.go('/settings'), - ), - ], - ), - body: roomsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Fehler: $e')), - data: (rooms) { - if (rooms.isEmpty) { - return const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.forum_outlined, size: 64), - SizedBox(height: 16), - Text('Noch keine Räume'), - ], - ), - ); - } - return ListView.separated( - itemCount: rooms.length, - separatorBuilder: (context, i) => const Divider(height: 1, indent: 72), - itemBuilder: (context, i) { - final room = rooms[i]; - return RoomListItem( - room: room, - onTap: () => context.go('/chat/${Uri.encodeComponent(room.id)}'), - ); - }, - ); - }, - ), - ); - } -} diff --git a/lib/features/rooms/rooms_panel.dart b/lib/features/rooms/rooms_panel.dart index 1b9d89b..3325e73 100644 --- a/lib/features/rooms/rooms_panel.dart +++ b/lib/features/rooms/rooms_panel.dart @@ -65,18 +65,28 @@ class _RoomsPanelState extends ConsumerState { activeSpaceId != 'dms' && activeSpaceId != 'rooms'; + final client = ref.watch(matrixClientProvider).valueOrNull; + final forceJoined = ref.watch(forceJoinedSpacesProvider); + final activeSpaceRoom = + isRealSpace ? client?.getRoomById(activeSpaceId) : null; + final isInvitedSpace = activeSpaceRoom?.membership == Membership.invite && + !forceJoined.contains(activeSpaceId); + return Container( color: pt.bg1, child: Column( children: [ _Header(pt: pt, spaceId: activeSpaceId), - _FilterInput( - controller: _filterCtrl, - pt: pt, - onChanged: (v) => setState(() => _filter = v), - ), + if (!isInvitedSpace) + _FilterInput( + controller: _filterCtrl, + pt: pt, + onChanged: (v) => setState(() => _filter = v), + ), Expanded( - child: isRealSpace + child: isInvitedSpace + ? _SpaceInviteView(space: activeSpaceRoom!, pt: pt) + : isRealSpace ? _SpaceRoomsList( spaceId: activeSpaceId, filter: _filter, @@ -111,21 +121,213 @@ class _RoomsPanelState extends ConsumerState { ), ), ), - if (call.isActive && !call.isVoiceChannel) MiniCallWidget(call: call), - if (voip.currentCall != null && - voip.currentCall!.state != CallState.kEnded) - MiniCallWidget(call: voip), - const UserPanel(), + // Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden. + // Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und + // Konto-Balken nebeneinander statt übereinander. + Builder(builder: (context) { + final hasVoipCall = voip.currentCall != null && + voip.currentCall!.state != CallState.kEnded; + final hasCallDock = call.isActive || hasVoipCall; + final isLandscapeCompact = + MediaQuery.sizeOf(context).height < 500; + + final callDock = AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (call.isActive) MiniCallWidget(call: call), + if (hasVoipCall) MiniCallWidget(call: voip), + ], + ), + ); + + if (isLandscapeCompact && hasCallDock) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: callDock), + const Expanded(child: UserPanel()), + ], + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [callDock, const UserPanel()], + ); + }), ], ), ); } } +// ─── Space invite view ───────────────────────────────────────────────────────── + +class _SpaceInviteView extends ConsumerStatefulWidget { + final Room space; + final PyramidTheme pt; + const _SpaceInviteView({required this.space, required this.pt}); + + @override + ConsumerState<_SpaceInviteView> createState() => _SpaceInviteViewState(); +} + +class _SpaceInviteViewState extends ConsumerState<_SpaceInviteView> { + bool _accepting = false; + bool _declining = false; + + Future _accept() async { + setState(() => _accepting = true); + final client = widget.space.client; + final id = widget.space.id; + try { + await client.joinRoomById(id).timeout(const Duration(seconds: 20)); + // Server confirmed the join. Switch to the channel list optimistically — + // the local sync membership may lag behind, so don't wait for it. + ref.read(forceJoinedSpacesProvider.notifier).add(id); + if (mounted) setState(() => _accepting = false); + } catch (e) { + if (mounted) { + setState(() => _accepting = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + 'Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'), + backgroundColor: widget.pt.danger, + )); + } + } + } + + Future _decline() async { + setState(() => _declining = true); + try { + await widget.space.leave(); + if (mounted) { + ref.read(activeSpaceIdProvider.notifier).state = 'rooms'; + } + } catch (_) { + if (mounted) setState(() => _declining = false); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final client = ref.watch(matrixClientProvider).valueOrNull; + final name = widget.space.getLocalizedDisplayname(); + final busy = _accepting || _declining; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: pt.accent.withAlpha(40), + borderRadius: BorderRadius.circular(20), + ), + child: client != null && widget.space.avatar != null + ? ClipRRect( + borderRadius: BorderRadius.circular(20), + child: MxcAvatar( + mxcUri: widget.space.avatar, + client: client, + size: 72, + borderRadius: BorderRadius.circular(20), + placeholder: (_) => Center( + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: pt.accent, + fontSize: 28, + fontWeight: FontWeight.w700), + ), + ), + ), + ) + : Center( + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: pt.accent, + fontSize: 28, + fontWeight: FontWeight.w700), + ), + ), + ), + const SizedBox(height: 16), + Text('Einladung zum Space', + style: TextStyle(color: pt.fgDim, fontSize: 13)), + const SizedBox(height: 4), + Text( + name, + textAlign: TextAlign.center, + style: TextStyle( + color: pt.fg, fontSize: 18, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: pt.danger, + side: BorderSide(color: pt.danger.withAlpha(100)), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + onPressed: busy ? null : _decline, + child: _declining + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2)) + : const Text('Ablehnen'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + onPressed: busy ? null : _accept, + child: _accepting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2)) + : const Text('Beitreten'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + // ─── Header ─────────────────────────────────────────────────────────────────── enum _SpaceMenuItem { - newDm, createRoom, joinRoom, discoverRooms, + newDm, createGroup, createRoom, joinRoom, discoverRooms, editSpace, adminSpace, leaveSpace, } @@ -159,6 +361,9 @@ class _Header extends ConsumerWidget { switch (item) { case _SpaceMenuItem.newDm: showDialog(context: context, builder: (_) => const NewDmDialog()); + case _SpaceMenuItem.createGroup: + showDialog( + context: context, builder: (_) => const CreateJoinDialog()); case _SpaceMenuItem.createRoom: showDialog( context: context, builder: (_) => const CreateJoinDialog()); @@ -237,6 +442,9 @@ class _Header extends ConsumerWidget { value: _SpaceMenuItem.newDm, child: _MenuRow( Icons.chat_bubble_outline_rounded, 'Neue Unterhaltung', pt)), + PopupMenuItem( + value: _SpaceMenuItem.createGroup, + child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)), ]; } else if (spaceId == 'rooms') { return [ @@ -270,14 +478,17 @@ class _Header extends ConsumerWidget { } } - // Check for space banner — prefer local override (set immediately on save) - // over the state event (only available after next Matrix sync) + // Check for banner — space banner for real spaces, server banner otherwise. final bannerOverrides = ref.watch(spaceBannerOverrideProvider); - final bannerUrl = isRealSpace && activeSpace != null - ? (bannerOverrides.containsKey(activeSpace.id) - ? bannerOverrides[activeSpace.id] - : activeSpace.getState(_kSpaceBannerType)?.content['url'] as String?) - : null; + final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {}; + String? bannerUrl; + if (isRealSpace && activeSpace != null) { + bannerUrl = bannerOverrides.containsKey(activeSpace.id) + ? bannerOverrides[activeSpace.id] + : activeSpace.getState(_kSpaceBannerType)?.content['url'] as String?; + } else if (client != null) { + bannerUrl = serverBanners[client.homeserver.toString()]; + } final hasBanner = bannerUrl != null && client != null; Widget controlsRow = Row( @@ -359,13 +570,9 @@ class _Header extends ConsumerWidget { onPressed: () => handleMenu(_SpaceMenuItem.adminSpace), ), if (!isRealSpace) - PyrIconBtn( - icon: const Icon(Icons.notifications_none_rounded), - tooltip: 'Notifications', - onPressed: () {}, - ), + _NotificationsButton(pt: pt, hasBanner: hasBanner), PopupMenuButton<_SpaceMenuItem>( - tooltip: 'Optionen', + tooltip: 'Erstellen', color: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), @@ -374,8 +581,8 @@ class _Header extends ConsumerWidget { onSelected: handleMenu, child: Padding( padding: const EdgeInsets.all(8), - child: Icon(Icons.keyboard_arrow_down_rounded, - color: hasBanner ? Colors.white70 : pt.fgDim, size: 16), + child: Icon(Icons.add_rounded, + color: hasBanner ? Colors.white70 : pt.fgDim, size: 18), ), ), ], @@ -460,6 +667,122 @@ class _MenuRow extends StatelessWidget { ]); } +// ─── Notifications button ────────────────────────────────────────────────────── + +/// Glocke im Header: Popup mit allen Räumen, die ungelesene Nachrichten oder +/// Mentions haben. Tippen öffnet den jeweiligen Raum. +class _NotificationsButton extends ConsumerWidget { + final PyramidTheme pt; + final bool hasBanner; + const _NotificationsButton({required this.pt, required this.hasBanner}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final rooms = ref.watch(roomListProvider).valueOrNull ?? []; + final unread = rooms + .where((r) => + r.membership == Membership.join && + (r.notificationCount > 0 || r.highlightCount > 0)) + .toList() + ..sort((a, b) { + final hl = b.highlightCount.compareTo(a.highlightCount); + if (hl != 0) return hl; + return b.notificationCount.compareTo(a.notificationCount); + }); + + return PopupMenuButton( + tooltip: 'Benachrichtigungen', + color: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: pt.border)), + offset: const Offset(0, 36), + onSelected: (roomId) { + ref.read(activeRoomIdProvider.notifier).state = roomId; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + }, + itemBuilder: (_) { + if (unread.isEmpty) { + return [ + PopupMenuItem( + enabled: false, + child: Text('Keine neuen Benachrichtigungen', + style: TextStyle(color: pt.fgDim, fontSize: 13)), + ), + ]; + } + return unread + .map((r) => PopupMenuItem( + value: r.id, + child: Row( + children: [ + Icon( + r.highlightCount > 0 + ? Icons.alternate_email_rounded + : (r.isDirectChat + ? Icons.chat_bubble_outline_rounded + : Icons.tag_rounded), + size: 14, + color: + r.highlightCount > 0 ? pt.danger : pt.fgMuted, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + r.getLocalizedDisplayname(), + style: TextStyle(color: pt.fg, fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: r.highlightCount > 0 ? pt.danger : pt.accent, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '${r.notificationCount > 0 ? r.notificationCount : r.highlightCount}', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w700), + ), + ), + ], + ), + )) + .toList(); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: Stack( + clipBehavior: Clip.none, + children: [ + Icon(Icons.notifications_none_rounded, + size: 18, color: hasBanner ? Colors.white70 : pt.fgDim), + if (unread.isNotEmpty) + Positioned( + top: -1, + right: -1, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: pt.danger, + shape: BoxShape.circle, + border: Border.all(color: pt.bg1, width: 1.5), + ), + ), + ), + ], + ), + ), + ); + } +} + // ─── Filter input ────────────────────────────────────────────────────────────── class _FilterInput extends StatelessWidget { @@ -534,6 +857,106 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> { // Local order override after drag-and-drop (room IDs in order per category key) final Map> _localOrder = {}; + // Joinable channels discovered via the space hierarchy (not yet a member). + List<_JoinableRoom> _joinable = []; + final Set _joining = {}; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final client = ref.read(matrixClientProvider).valueOrNull; + if (client != null) _loadHierarchy(client); + }); + } + + @override + void didUpdateWidget(covariant _SpaceRoomsList old) { + super.didUpdateWidget(old); + if (old.spaceId != widget.spaceId) { + setState(() => _joinable = []); + final client = ref.read(matrixClientProvider).valueOrNull; + if (client != null) _loadHierarchy(client); + } + } + + /// Queries the server-side space hierarchy and keeps only rooms the user is + /// not yet a member of — these are the channels they can still join. + Future _loadHierarchy(Client client) async { + try { + final resp = await client.getSpaceHierarchy( + widget.spaceId, + maxDepth: 3, + suggestedOnly: false, + ); + final joinable = <_JoinableRoom>[]; + for (final chunk in resp.rooms) { + if (chunk.roomId == widget.spaceId) continue; + if (chunk.roomType == 'm.space') continue; // skip sub-spaces + // Keep every channel the hierarchy reports. The build method filters out + // the ones already rendered in the joined sections — relying on local + // membership here is unreliable because Continuwuity's sync lags behind. + joinable.add(_JoinableRoom( + roomId: chunk.roomId, + name: chunk.name ?? chunk.canonicalAlias ?? chunk.roomId, + isVoice: chunk.roomType == _kVoiceRoomType, + memberCount: chunk.numJoinedMembers, + )); + } + if (mounted) setState(() => _joinable = joinable); + } catch (_) { + // Hierarchy endpoint unavailable / no permission — silently ignore. + } + } + + Future _joinAndEnter(Client client, _JoinableRoom jr) async { + setState(() => _joining.add(jr.roomId)); + try { + await client.joinRoomById(jr.roomId).timeout(const Duration(seconds: 20)); + // Give the sync a brief chance to deliver the room object, but don't block + // on it — the local membership may lag behind the server. + Room? room = client.getRoomById(jr.roomId); + if (room == null) { + try { + await client.onSync.stream + .firstWhere((_) => client.getRoomById(jr.roomId) != null) + .timeout(const Duration(seconds: 4)); + } catch (_) {} + room = client.getRoomById(jr.roomId); + } + if (!mounted) return; + setState(() => _joining.remove(jr.roomId)); + if (jr.isVoice) { + // LiveKit only needs the room id + a display name, so connect even if the + // matrix room object hasn't synced locally yet. + final call = ref.read(callStateProvider); + if (call.isActive) await call.hangUp(); + ref.read(activeVoiceRoomIdProvider.notifier).state = jr.roomId; + await call.startCall( + roomName: jr.roomId, + roomDisplayName: room?.getLocalizedDisplayname() ?? jr.name, + identity: client.userID ?? '', + audioOnly: true, + voiceChannel: true, + matrixClient: client, + matrixRoomId: jr.roomId, + ); + } else if (room != null) { + ref.read(activeRoomIdProvider.notifier).state = room.id; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + } + } catch (e) { + if (mounted) { + setState(() => _joining.remove(jr.roomId)); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text('Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'), + backgroundColor: widget.pt.danger, + )); + } + } + } + Future _applyReorder( Room space, String categoryKey, List newOrder) async { try { @@ -600,6 +1023,10 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> { .where((r) => r.membership == Membership.invite) .toList(); + // IDs already rendered in the joined/invited sections — used to avoid showing + // them again under "available channels" from the hierarchy. + final shownIds = {...invites.map((r) => r.id)}; + void selectRoom(Room room) { ref.read(activeRoomIdProvider.notifier).state = room.id; ref.read(viewModeProvider.notifier).state = ViewMode.chat; @@ -643,6 +1070,7 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> { .where((r) => r.membership != Membership.leave && !r.isSpace) .toList(); + shownIds.addAll(catChildren.map((r) => r.id)); final filtered = catChildren .where((r) => widget.filter.isEmpty || @@ -677,6 +1105,7 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> { .map((c) => client.getRoomById(c.roomId!)) .whereType() .toList(); + shownIds.addAll(directRooms.map((r) => r.id)); // Apply local order override if present final localOrd = _localOrder['__direct__']; @@ -698,6 +1127,14 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> { .contains(widget.filter.toLowerCase())) .toList(); + // Joinable channels from the hierarchy that aren't already listed above. + final visibleJoinable = _joinable + .where((jr) => + !shownIds.contains(jr.roomId) && + (widget.filter.isEmpty || + jr.name.toLowerCase().contains(widget.filter.toLowerCase()))) + .toList(); + // Build the complete list return ListView( padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), @@ -776,9 +1213,28 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> { ); }), ], + // Joinable channels from the space hierarchy (not yet a member) + if (visibleJoinable.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 8, 2), + child: Text('VERFÜGBARE KANÄLE', + style: TextStyle( + color: widget.pt.fgDim, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.6)), + ), + ...visibleJoinable.map((jr) => _JoinableRoomItem( + jr: jr, + joining: _joining.contains(jr.roomId), + pt: widget.pt, + onJoin: () => _joinAndEnter(client, jr), + )), + ], if (filteredDirect.isEmpty && categoryWidgets.isEmpty && - invites.isEmpty) + invites.isEmpty && + visibleJoinable.isEmpty) Padding( padding: const EdgeInsets.all(16), child: Center( @@ -791,6 +1247,110 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> { } } +// ─── Joinable room (from space hierarchy) ───────────────────────────────────── + +class _JoinableRoom { + final String roomId; + final String name; + final bool isVoice; + final int memberCount; + const _JoinableRoom({ + required this.roomId, + required this.name, + required this.isVoice, + required this.memberCount, + }); +} + +class _JoinableRoomItem extends StatefulWidget { + final _JoinableRoom jr; + final bool joining; + final PyramidTheme pt; + final VoidCallback onJoin; + const _JoinableRoomItem({ + required this.jr, + required this.joining, + required this.pt, + required this.onJoin, + }); + + @override + State<_JoinableRoomItem> createState() => _JoinableRoomItemState(); +} + +class _JoinableRoomItemState extends State<_JoinableRoomItem> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final jr = widget.jr; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.joining ? null : widget.onJoin, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.symmetric(vertical: 1), + padding: EdgeInsets.symmetric( + horizontal: pt.rowPadX, vertical: pt.rowPadY), + decoration: BoxDecoration( + color: _hovered ? pt.bgHover : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: Icon( + jr.isVoice ? Icons.mic_rounded : Icons.tag_rounded, + size: 14, + color: pt.fgDim, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + jr.name, + style: TextStyle( + color: pt.fgMuted, + fontSize: 14, + fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.joining) + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _hovered ? pt.accent : pt.bg3, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Beitreten', + style: TextStyle( + color: _hovered ? pt.accentFg : pt.fgMuted, + fontSize: 11, + fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ), + ); + } +} + // ─── Category header ─────────────────────────────────────────────────────────── class _CategoryHeader extends StatefulWidget { @@ -1258,6 +1818,67 @@ class _RoomItemState extends ConsumerState<_RoomItem> { } } + /// Touch-friendly action menu — opened via long-press (the hover menu is not + /// reachable on touch devices). + void _showContextMenu() { + final pt = widget.pt; + final room = widget.room; + final isDm = room.isDirectChat; + + Widget action(IconData icon, String label, VoidCallback onTap, + {bool danger = false}) { + return ListTile( + leading: Icon(icon, size: 20, color: danger ? pt.danger : pt.fgMuted), + title: Text(label, + style: TextStyle( + color: danger ? pt.danger : pt.fg, fontSize: 14)), + onTap: () { + Navigator.pop(context); + onTap(); + }, + ); + } + + showModalBottomSheet( + context: context, + backgroundColor: pt.bg2, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Row(children: [ + Expanded( + child: Text(room.getLocalizedDisplayname(), + style: TextStyle( + color: pt.fg, + fontSize: 15, + fontWeight: FontWeight.w700), + overflow: TextOverflow.ellipsis), + ), + ]), + ), + Divider(height: 1, color: pt.border), + if (!isDm) ...[ + action(Icons.person_add_outlined, 'Nutzer einladen', _inviteUser), + action(Icons.settings_rounded, 'Einstellungen', _showRoomSettings), + ], + action( + Icons.exit_to_app_rounded, + isDm ? 'Chat verlassen' : 'Raum verlassen', + isDm ? _leaveDm : _leaveRoom, + danger: true, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final pt = widget.pt; @@ -1279,6 +1900,7 @@ class _RoomItemState extends ConsumerState<_RoomItem> { onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, + onLongPress: _showContextMenu, child: Stack( children: [ if (active) @@ -1693,7 +2315,15 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> { Color? _dotColor() { final p = _presence; if (p == null) return null; - if (p.currentlyActive == true) return PyramidColors.online; + // Server-Presence kann veraltet sein (z.B. nie als offline gemeldet). + // Nur als online werten, wenn die letzte Aktivität wirklich frisch ist. + final last = p.lastActiveTimestamp; + final fresh = last != null && + DateTime.now().difference(last) < const Duration(minutes: 5); + if (p.presence == PresenceType.online && + (p.currentlyActive == true || fresh)) { + return PyramidColors.online; + } if (p.presence == PresenceType.unavailable) return PyramidColors.away; return null; } diff --git a/lib/features/rooms/rooms_provider.dart b/lib/features/rooms/rooms_provider.dart index d4a5ccf..999aa47 100644 --- a/lib/features/rooms/rooms_provider.dart +++ b/lib/features/rooms/rooms_provider.dart @@ -12,6 +12,22 @@ final roomListProvider = StreamProvider>((ref) async* { } }); +final dmUnreadCountProvider = StreamProvider((ref) async* { + final client = await ref.watch(matrixClientProvider.future); + + yield _countDmUnread(client); + + await for (final _ in client.onSync.stream) { + yield _countDmUnread(client); + } +}); + +int _countDmUnread(Client client) { + return client.rooms + .where((r) => !r.isSpace && r.isDirectChat && r.notificationCount > 0) + .length; +} + List _sortedRooms(Client client) { final rooms = client.rooms .where((r) => !r.isSpace) diff --git a/lib/features/rooms/user_panel.dart b/lib/features/rooms/user_panel.dart new file mode 100644 index 0000000..a616184 --- /dev/null +++ b/lib/features/rooms/user_panel.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/widgets/hover_region.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; +import 'package:pyramid/widgets/pyramid_logo.dart'; + +final _ownProfileProvider = FutureProvider.autoDispose((ref) async { + final client = await ref.watch(matrixClientProvider.future); + final userId = client.userID; + if (userId == null) return null; + try { + return await client.getProfileFromUserId(userId); + } catch (_) { + return null; + } +}); + +class UserPanel extends ConsumerWidget { + const UserPanel({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pt = PyramidTheme.of(context); + final clientAsync = ref.watch(matrixClientProvider); + final profile = ref.watch(_ownProfileProvider).valueOrNull; + + final client = clientAsync.valueOrNull; + final fallbackName = client?.userID?.split(':').first.replaceFirst('@', '') ?? '...'; + final displayName = profile?.displayName ?? fallbackName; + final avatarUri = profile?.avatarUrl; + + return Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Container( + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: pt.border), + ), + child: _ProfileRow( + pt: pt, + client: client, + avatarUri: avatarUri, + displayName: displayName, + ref: ref, + ), + ), + ); + } +} + +class _ProfileRow extends StatelessWidget { + final PyramidTheme pt; + final Client? client; + final Uri? avatarUri; + final String displayName; + final WidgetRef ref; + + const _ProfileRow({ + required this.pt, + required this.client, + required this.avatarUri, + required this.displayName, + required this.ref, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + if (client != null && avatarUri != null) + MxcAvatar( + mxcUri: avatarUri!, + client: client!, + size: 32, + borderRadius: BorderRadius.circular(4), + placeholder: (_) => _InitialAvatar(name: displayName, radius: 4), + ) + else + _InitialAvatar(name: displayName, radius: 4), + Positioned( + bottom: -2, + right: -2, + child: PresenceDot(status: 'online', size: 10, borderColor: pt.bg2), + ), + ], + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + displayName, + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + 'online', + style: TextStyle(color: pt.fgMuted, fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.settings_rounded), + tooltip: 'Einstellungen', + onPressed: () => ref.read(activeModalProvider.notifier).state = ModalKind.settings, + ), + ], + ), + ); + } +} + +class _InitialAvatar extends StatelessWidget { + final String name; + final double radius; + final double size; + const _InitialAvatar({required this.name, required this.radius, this.size = 32}); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF6B9D), Color(0xFFC471F5)], + ), + borderRadius: BorderRadius.circular(radius), + ), + child: Center( + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: Colors.white, + fontSize: size * 0.44, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/lib/features/spaces/space_admin_dialog.dart b/lib/features/spaces/space_admin_dialog.dart new file mode 100644 index 0000000..3b0b8cd --- /dev/null +++ b/lib/features/spaces/space_admin_dialog.dart @@ -0,0 +1,2278 @@ +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart' hide Visibility; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/widgets/banner_crop_dialog.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; + +const _kSpaceBannerEvent = 'io.pyramid.space.banner'; + +/// Ändert die Power-Levels auf Basis des aktuellen SERVER-Stands (nicht des +/// lokalen Caches!). Das SDK-`setPower` und ein lokal fehlendes +/// power_levels-Event haben sonst zur Folge, dass die users-Map des Servers +/// mit einem Minimal-Event überschrieben wird — und Admins sich selbst +/// aussperren (genau das ist hier bereits passiert). +Future updatePowerLevelsSafely( + Room room, + void Function(Map content) mutate, +) async { + Map content; + try { + content = Map.from( + await room.client + .getRoomStateWithKey(room.id, EventTypes.RoomPowerLevels, ''), + ); + } on MatrixException catch (e) { + if (e.errcode == 'M_NOT_FOUND') { + content = {}; + } else { + rethrow; + } + } + final users = content['users']; + content['users'] = + users is Map ? Map.from(users) : {}; + mutate(content); + + // Selbst-Aussperr-Schutz: Nach der Änderung muss der eigene Account immer + // noch hoch genug stehen, um Power-Levels erneut zu ändern — sonst gäbe es + // kein Zurück mehr. Greift bei versehentlicher Selbst-Degradierung ODER + // wenn ein neu geschriebenes Event den eigenen Eintrag nicht enthält. + final myId = room.client.userID; + if (myId != null) { + int asInt(Object? v, int fallback) => v is int ? v : fallback; + final usersMap = content['users'] as Map; + final myLevel = asInt(usersMap[myId], asInt(content['users_default'], 0)); + final events = content['events']; + final neededForPl = (events is Map && events['m.room.power_levels'] != null) + ? asInt(events['m.room.power_levels'], 50) + : asInt(content['state_default'], 50); + if (myLevel < neededForPl) { + throw Exception( + 'Abgebrochen, um eine Selbst-Aussperrung zu verhindern: ' + 'Diese Änderung würde dein Level auf $myLevel senken, ' + 'aber zum Verwalten der Berechtigungen sind $neededForPl nötig.', + ); + } + } + + await room.client + .setRoomStateWithKey(room.id, EventTypes.RoomPowerLevels, '', content); +} + +// Power level labels +const _kPowerAdmin = 100; +const _kPowerMod = 50; +const _kPowerMember = 0; + +String _powerLabel(int level) { + if (level >= _kPowerAdmin) return 'Admin'; + if (level >= _kPowerMod) return 'Moderator'; + return 'Mitglied'; +} + +Color _powerColor(int level, PyramidTheme pt) { + if (level >= _kPowerAdmin) return const Color(0xFFED4245); + if (level >= _kPowerMod) return const Color(0xFFF59E0B); + return pt.fgDim; +} + +class SpaceAdminDialog extends ConsumerStatefulWidget { + final Room space; + const SpaceAdminDialog({super.key, required this.space}); + + @override + ConsumerState createState() => _SpaceAdminDialogState(); +} + +class _SpaceAdminDialogState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabs; + + @override + void initState() { + super.initState(); + _tabs = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _tabs.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return Dialog( + backgroundColor: pt.bg1, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rLg)), + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 680), + child: Column( + children: [ + _DialogHeader(space: widget.space, pt: pt, tabs: _tabs), + Expanded( + child: TabBarView( + controller: _tabs, + children: [ + _OverviewTab(space: widget.space), + _MembersTab(space: widget.space), + _PermissionsTab(space: widget.space), + _ChannelsTab(space: widget.space), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ─── Header + TabBar ───────────────────────────────────────────────────────── + +class _DialogHeader extends ConsumerWidget { + final Room space; + final PyramidTheme pt; + final TabController tabs; + const _DialogHeader({required this.space, required this.pt, required this.tabs}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final client = ref.watch(matrixClientProvider).valueOrNull; + final name = space.getLocalizedDisplayname(); + final avatarUri = space.avatar; + final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; + + Widget avatar; + if (client != null && avatarUri != null) { + avatar = MxcAvatar( + mxcUri: avatarUri, + client: client, + size: 40, + borderRadius: BorderRadius.circular(pt.rBase), + placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 40), + ); + } else { + avatar = _InitialAvatar(letter: letter, pt: pt, size: 40); + } + + return Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: pt.border)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 12, 12), + child: Row( + children: [ + avatar, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, + style: TextStyle( + color: pt.fg, + fontSize: 17, + fontWeight: FontWeight.w700)), + Text('Space-Einstellungen', + style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + IconButton( + icon: Icon(Icons.close, color: pt.fgDim, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + TabBar( + controller: tabs, + labelColor: pt.accent, + unselectedLabelColor: pt.fgDim, + indicatorColor: pt.accent, + indicatorSize: TabBarIndicatorSize.label, + tabs: const [ + Tab(text: 'Übersicht'), + Tab(text: 'Mitglieder'), + Tab(text: 'Berechtigungen'), + Tab(text: 'Kanäle'), + ], + ), + ], + ), + ); + } +} + +// ─── Overview Tab ───────────────────────────────────────────────────────────── + +class _OverviewTab extends ConsumerStatefulWidget { + final Room space; + const _OverviewTab({required this.space}); + + @override + ConsumerState<_OverviewTab> createState() => _OverviewTabState(); +} + +class _OverviewTabState extends ConsumerState<_OverviewTab> { + late TextEditingController _nameCtrl; + late TextEditingController _topicCtrl; + bool _saving = false; + String? _error; + String? _success; + Uint8List? _pendingBanner; + String? _bannerMxcUri; + Uint8List? _pendingAvatar; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController( + text: widget.space.getLocalizedDisplayname()); + _topicCtrl = TextEditingController(text: widget.space.topic); + // Prefer local override (set on last save) over room state event + final overrides = ref.read(spaceBannerOverrideProvider); + _bannerMxcUri = overrides.containsKey(widget.space.id) + ? overrides[widget.space.id] + : widget.space.getState(_kSpaceBannerEvent)?.content['url'] as String?; + } + + @override + void dispose() { + _nameCtrl.dispose(); + _topicCtrl.dispose(); + super.dispose(); + } + + Future _pickBanner() async { + final result = await FilePicker.platform + .pickFiles(type: FileType.image, withData: true); + final file = result?.files.first; + if (file == null) return; + final raw = file.bytes ?? await file.xFile.readAsBytes(); + if (!mounted) return; + final cropped = await showDialog( + context: context, + builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4), + ); + if (cropped == null || !mounted) return; + setState(() => _pendingBanner = cropped); + } + + Future _pickAvatar() async { + final result = await FilePicker.platform + .pickFiles(type: FileType.image, withData: true); + final file = result?.files.first; + if (file == null) return; + final raw = file.bytes ?? await file.xFile.readAsBytes(); + if (!mounted) return; + setState(() => _pendingAvatar = raw); + } + + Future _removeAvatar() async { + setState(() => _pendingAvatar = null); + try { + await widget.space.client.setRoomStateWithKey( + widget.space.id, EventTypes.RoomAvatar, '', {}); + } catch (_) {} + } + + Future _removeBanner() async { + setState(() { _pendingBanner = null; _bannerMxcUri = null; }); + try { + await widget.space.client.setRoomStateWithKey( + widget.space.id, _kSpaceBannerEvent, '', {}); + } catch (_) {} + } + + Future _save() async { + setState(() { _saving = true; _error = null; _success = null; }); + // Welcher Schritt gerade läuft — für präzise Fehlermeldungen statt eines + // pauschalen "keine Berechtigung" (das auch von Upload-Fehlern kam). + var step = 'Speichern'; + try { + final client = widget.space.client; + final newName = _nameCtrl.text.trim(); + final newTopic = _topicCtrl.text.trim(); + if (newName.isNotEmpty && + newName != widget.space.getLocalizedDisplayname()) { + step = 'Name ändern'; + await widget.space.setName(newName); + } + if (newTopic != widget.space.topic) { + step = 'Beschreibung ändern'; + await widget.space.setDescription(newTopic); + } + if (_pendingAvatar != null) { + step = 'Avatar hochladen'; + final uri = await client.uploadContent( + _pendingAvatar!, + filename: 'space_avatar.jpg', + contentType: 'image/jpeg', + ); + step = 'Avatar setzen'; + await _setStateWithRepair( + client, + EventTypes.RoomAvatar, + {'url': uri.toString()}, + ); + setState(() => _pendingAvatar = null); + } + if (_pendingBanner != null) { + step = 'Banner hochladen'; + final uri = await client.uploadContent( + _pendingBanner!, + filename: 'space_banner.jpg', + contentType: 'image/jpeg', + ); + step = 'Banner setzen'; + await _setStateWithRepair( + client, + _kSpaceBannerEvent, + {'url': uri.toString()}, + ); + final uriStr = uri.toString(); + // Update local override immediately so header updates before next sync + ref.read(spaceBannerOverrideProvider.notifier).update( + (m) => {...m, widget.space.id: uriStr}, + ); + setState(() { + _bannerMxcUri = uriStr; + _pendingBanner = null; + }); + } + setState(() { _success = 'Gespeichert'; }); + } catch (e) { + // Server-Fehlertext anzeigen (MatrixException liefert lesbare Meldung) — + // nicht pauschal auf "Admin-Level erforderlich" mappen. + final raw = e is MatrixException + ? '${e.errcode}: ${e.errorMessage}' + : e.toString().split('\n').first; + var detail = ''; + if (e is MatrixException && e.errcode == 'M_FORBIDDEN') { + detail = '\n${_permDiagnostics()}'; + // Zusätzlich die SERVER-Sicht der Power-Levels holen — die lokale + // Kopie kann von der des Servers abweichen. + try { + final pl = await widget.space.client.getRoomStateWithKey( + widget.space.id, EventTypes.RoomPowerLevels, ''); + detail += + '\nServer-PL: users=${pl['users']}, state_default=${pl['state_default']}, events=${pl['events']}'; + } on MatrixException catch (se) { + detail += '\nServer-PL: ${se.errcode} (Event fehlt auch serverseitig)'; + } catch (_) {} + } + setState(() { + _error = '$step fehlgeschlagen – $raw$detail'; + }); + } finally { + setState(() { _saving = false; }); + } + } + + /// Sendet ein State-Event; bei M_FORBIDDEN in einem Raum OHNE + /// power_levels-Event (Continuwuity-Sonderfall: Server verweigert dann + /// Custom-State trotz Creator-Rechten) werden einmalig Standard-Power-Levels + /// mit uns als Admin angelegt und der Vorgang wiederholt. + Future _setStateWithRepair( + Client client, + String type, + Map content, + ) async { + try { + await client.setRoomStateWithKey(widget.space.id, type, '', content); + } on MatrixException catch (e) { + if (e.errcode != 'M_FORBIDDEN') rethrow; + final repaired = await _ensurePowerLevels(client); + if (!repaired) rethrow; + await client.setRoomStateWithKey(widget.space.id, type, '', content); + } + } + + /// Legt Standard-Power-Levels an, falls der Raum keine hat. + /// Gibt true zurück, wenn das Event neu angelegt wurde. + Future _ensurePowerLevels(Client client) async { + try { + await client.getRoomStateWithKey( + widget.space.id, EventTypes.RoomPowerLevels, ''); + return false; // existiert bereits — Ablehnung hat andere Ursache + } catch (_) { + // fehlt (M_NOT_FOUND) → anlegen + } + try { + await client.setRoomStateWithKey( + widget.space.id, + EventTypes.RoomPowerLevels, + '', + { + 'users': {client.userID!: 100}, + 'users_default': 0, + 'events': const {}, + 'events_default': 0, + 'state_default': 50, + 'ban': 50, + 'kick': 50, + 'redact': 50, + 'invite': 0, + 'notifications': {'room': 50}, + }, + ); + return true; + } catch (_) { + return false; + } + } + + /// Kompakte Berechtigungs-Diagnose für M_FORBIDDEN-Fehler: zeigt, wie der + /// Server die Lage vermutlich sieht (Raumversion, Creator, Power-Level). + String _permDiagnostics() { + try { + final space = widget.space; + final create = space.getState(EventTypes.RoomCreate); + final version = create?.content['room_version'] ?? '1'; + final creator = create?.senderId ?? '?'; + final own = space.ownPowerLevel; + final needed = space.powerForChangingStateEvent(_kSpaceBannerEvent); + final hasPl = space.getState(EventTypes.RoomPowerLevels) != null; + return 'Diagnose: dein Level $own, benötigt $needed, ' + 'Raumversion $version, Creator $creator' + '${hasPl ? '' : ', kein power_levels-Event vorhanden!'}'; + } catch (_) { + return ''; + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final client = ref.watch(matrixClientProvider).valueOrNull; + // SDK-Berechtigungsprüfung (berücksichtigt events-Overrides und + // state_default) statt hartcodiertem Admin-Level 100. + final canEdit = widget.space.canChangeStateEvent(EventTypes.RoomName) || + widget.space.ownPowerLevel >= _kPowerAdmin; + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Banner ──────────────────────────────────────────────────────── + _SectionLabel('Banner', pt), + const SizedBox(height: 6), + GestureDetector( + onTap: canEdit ? _pickBanner : null, + child: Stack( + children: [ + AspectRatio( + aspectRatio: 2.4, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: pt.bg0, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + clipBehavior: Clip.hardEdge, + child: _pendingBanner != null + ? Image.memory(_pendingBanner!, + fit: BoxFit.cover, width: double.infinity) + : (_bannerMxcUri != null && client != null + ? MxcImage( + mxcUri: _bannerMxcUri!, + client: client, + fit: BoxFit.cover, + width: double.infinity, + placeholder: Container(color: pt.bg0), + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_photo_alternate_outlined, + size: 24, color: pt.fgDim), + const SizedBox(height: 4), + Text('Banner hinzufügen', + style: TextStyle( + color: pt.fgDim, fontSize: 12)), + ], + ), + )), + ), // Container + ), // AspectRatio + if (canEdit) + Positioned( + right: 6, + bottom: 6, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_bannerMxcUri != null || _pendingBanner != null) + GestureDetector( + onTap: _removeBanner, + child: Container( + margin: const EdgeInsets.only(right: 4), + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFED4245).withAlpha(200), + borderRadius: BorderRadius.circular(5), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.delete_outline, + size: 11, color: Colors.white), + SizedBox(width: 3), + Text('Entfernen', + style: TextStyle( + color: Colors.white, fontSize: 11)), + ], + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: pt.bg0.withAlpha(200), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: pt.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.edit_rounded, + size: 11, color: pt.fgMuted), + const SizedBox(width: 3), + Text('Banner ändern', + style: TextStyle( + color: pt.fgMuted, fontSize: 11)), + ], + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + // ── Avatar ─────────────────────────────────────────────────────── + _SectionLabel('Serverbild', pt), + const SizedBox(height: 8), + _AvatarEditor( + space: widget.space, + pendingAvatar: _pendingAvatar, + canEdit: canEdit, + onPick: _pickAvatar, + onRemove: _removeAvatar, + pt: pt, + client: client, + ), + const SizedBox(height: 16), + // ── Name / Topic ───────────────────────────────────────────────── + _SectionLabel('Name', pt), + const SizedBox(height: 6), + _Field(controller: _nameCtrl, hint: 'Space-Name', pt: pt), + const SizedBox(height: 16), + _SectionLabel('Beschreibung', pt), + const SizedBox(height: 6), + _Field( + controller: _topicCtrl, + hint: 'Worum geht es in diesem Space?', + maxLines: 4, + pt: pt), + const SizedBox(height: 8), + _SpaceVisibilityToggle(space: widget.space), + const SizedBox(height: 20), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_error!, + style: const TextStyle(color: Color(0xFFED4245), fontSize: 12)), + ), + if (_success != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_success!, + style: const TextStyle(color: Color(0xFF57F287), fontSize: 12)), + ), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + style: FilledButton.styleFrom( + backgroundColor: pt.accent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rBase)), + ), + child: _saving + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: pt.bg0)) + : const Text('Speichern', + style: TextStyle( + color: Colors.white, fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(height: 24), + _DangerZone(space: widget.space), + ], + ), + ); + } +} + +class _SpaceVisibilityToggle extends ConsumerStatefulWidget { + final Room space; + const _SpaceVisibilityToggle({required this.space}); + + @override + ConsumerState<_SpaceVisibilityToggle> createState() => + _SpaceVisibilityToggleState(); +} + +class _SpaceVisibilityToggleState + extends ConsumerState<_SpaceVisibilityToggle> { + bool? _isPublic; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final joinRules = widget.space.joinRules; + setState(() { + _isPublic = joinRules == JoinRules.public; + _loading = false; + }); + } catch (_) { + setState(() => _loading = false); + } + } + + Future _toggle(bool value) async { + setState(() => _isPublic = value); + try { + await widget.space.setJoinRules( + value ? JoinRules.public : JoinRules.invite); + } catch (_) { + setState(() => _isPublic = !value); + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + if (_loading) return const SizedBox.shrink(); + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Öffentlicher Space', + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w600)), + Text('Jeder kann diesem Space beitreten', + style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + Switch( + value: _isPublic ?? false, + onChanged: _toggle, + activeThumbColor: pt.accent, + ), + ], + ); + } +} + +class _DangerZone extends ConsumerWidget { + final Room space; + const _DangerZone({required this.space}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pt = PyramidTheme.of(context); + return Container( + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFED4245).withAlpha(80)), + borderRadius: BorderRadius.circular(pt.rBase), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Gefahrenzone', + style: TextStyle( + color: const Color(0xFFED4245), + fontSize: 13, + fontWeight: FontWeight.w700)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Space verlassen', + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w600)), + Text('Du verlässt diesen Space', + style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + OutlinedButton( + onPressed: () => _confirmLeave(context, ref), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFED4245), + side: const BorderSide(color: Color(0xFFED4245)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Verlassen'), + ), + ], + ), + ], + ), + ); + } + + void _confirmLeave(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Space verlassen?'), + content: Text( + 'Möchtest du "${space.getLocalizedDisplayname()}" wirklich verlassen?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Abbrechen')), + TextButton( + onPressed: () async { + Navigator.pop(context); + Navigator.pop(context); + await space.leave(); + }, + child: const Text('Verlassen', + style: TextStyle(color: Color(0xFFED4245))), + ), + ], + ), + ); + } +} + +// ─── Members Tab ────────────────────────────────────────────────────────────── + +class _MembersTab extends ConsumerStatefulWidget { + final Room space; + const _MembersTab({required this.space}); + + @override + ConsumerState<_MembersTab> createState() => _MembersTabState(); +} + +class _MembersTabState extends ConsumerState<_MembersTab> { + List? _members; + bool _loading = true; + String? _error; + String _filter = ''; + final _filterCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _filterCtrl.dispose(); + super.dispose(); + } + + Future _load() async { + try { + final members = + await widget.space.requestParticipants([Membership.join, Membership.invite]); + if (mounted) setState(() { _members = members; _loading = false; }); + } catch (e) { + if (mounted) setState(() { _error = e.toString(); _loading = false; }); + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + + if (_loading) { + return Center( + child: CircularProgressIndicator(color: pt.accent, strokeWidth: 2)); + } + if (_error != null) { + return Center( + child: Text(_error!, style: TextStyle(color: pt.fgDim, fontSize: 13))); + } + + final members = _members ?? []; + final filtered = _filter.isEmpty + ? members + : members + .where((m) => + (m.displayName ?? m.id) + .toLowerCase() + .contains(_filter.toLowerCase()) || + m.id.toLowerCase().contains(_filter.toLowerCase())) + .toList() + ..sort((a, b) => (b.powerLevel).compareTo(a.powerLevel)); + + final ownPower = widget.space.ownPowerLevel; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: [ + Expanded( + child: _SearchField( + controller: _filterCtrl, + hint: 'Mitglieder suchen…', + pt: pt, + onChanged: (v) => setState(() => _filter = v), + ), + ), + if (ownPower >= _kPowerMod) ...[ + const SizedBox(width: 8), + _InviteButton(space: widget.space), + ], + ], + ), + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: filtered.length, + itemBuilder: (context, i) { + final member = filtered[i]; + return _MemberTile( + member: member, + space: widget.space, + ownPower: ownPower, + onChanged: _load, + ); + }, + ), + ), + ], + ); + } +} + +class _InviteButton extends ConsumerStatefulWidget { + final Room space; + const _InviteButton({required this.space}); + + @override + ConsumerState<_InviteButton> createState() => _InviteButtonState(); +} + +class _InviteButtonState extends ConsumerState<_InviteButton> { + bool _inviting = false; + String? _inviteError; + + void _showInvite() { + final pt = PyramidTheme.of(context); + final ctrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setS) => AlertDialog( + backgroundColor: pt.bg1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rLg)), + title: Text('Zu Space einladen', + style: TextStyle(color: pt.fg, fontWeight: FontWeight.w700)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Field(controller: ctrl, hint: '@user:server.org', pt: pt), + const SizedBox(height: 6), + Text('Lädt zu Space und allen Kanälen ein.', + style: TextStyle(color: pt.fgDim, fontSize: 11)), + if (_inviteError != null) ...[ + const SizedBox(height: 6), + Text(_inviteError!, + style: TextStyle(color: pt.danger, fontSize: 12)), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Abbrechen')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: pt.accent), + onPressed: _inviting + ? null + : () async { + final userId = ctrl.text.trim(); + if (userId.isEmpty) return; + setS(() { _inviting = true; _inviteError = null; }); + try { + // 1. Invite to space itself + await widget.space.invite(userId); + // 2. Invite to every joined child room + final client = widget.space.client; + for (final child in widget.space.spaceChildren) { + final roomId = child.roomId; + if (roomId == null) continue; + final room = client.getRoomById(roomId); + if (room == null || room.membership == Membership.leave) continue; + try { + await room.invite(userId); + } catch (_) {} + } + if (ctx.mounted) Navigator.pop(ctx); + } catch (e) { + setS(() { _inviteError = e.toString().split('\n').first; }); + } finally { + setS(() => _inviting = false); + } + }, + child: _inviting + ? const SizedBox( + width: 16, height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('Einladen', + style: TextStyle(color: Colors.white)), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return IconButton( + icon: Icon(Icons.person_add_outlined, color: pt.accent, size: 20), + tooltip: 'Einladen', + onPressed: _showInvite, + ); + } +} + +class _MemberTile extends ConsumerStatefulWidget { + final User member; + final Room space; + final int ownPower; + final VoidCallback onChanged; + + const _MemberTile({ + required this.member, + required this.space, + required this.ownPower, + required this.onChanged, + }); + + @override + ConsumerState<_MemberTile> createState() => _MemberTileState(); +} + +class _MemberTileState extends ConsumerState<_MemberTile> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final member = widget.member; + final power = member.powerLevel; + final name = member.displayName ?? member.id; + final client = ref.watch(matrixClientProvider).valueOrNull; + final avatarUri = member.avatarUrl; + final isMe = member.id == widget.space.client.userID; + final canManage = widget.ownPower > power && !isMe; + + Widget avatar; + final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; + if (client != null && avatarUri != null) { + avatar = MxcAvatar( + mxcUri: avatarUri, + client: client, + size: 32, + borderRadius: BorderRadius.circular(16), + placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 32), + ); + } else { + avatar = _InitialAvatar(letter: letter, pt: pt, size: 32); + } + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 1), + decoration: BoxDecoration( + color: _hovered ? pt.bg2 : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + leading: avatar, + title: Text(name, + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis), + subtitle: Text(member.id, + style: TextStyle(color: pt.fgDim, fontSize: 11), + overflow: TextOverflow.ellipsis), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: _powerColor(power, pt).withAlpha(30), + borderRadius: BorderRadius.circular(6), + ), + child: Text(_powerLabel(power), + style: TextStyle( + color: _powerColor(power, pt), + fontSize: 11, + fontWeight: FontWeight.w600)), + ), + if (canManage) ...[ + const SizedBox(width: 4), + _MemberActions( + member: member, + space: widget.space, + ownPower: widget.ownPower, + onChanged: widget.onChanged), + ], + ], + ), + ), + ), + ); + } +} + +class _MemberActions extends ConsumerWidget { + final User member; + final Room space; + final int ownPower; + final VoidCallback onChanged; + + const _MemberActions({ + required this.member, + required this.space, + required this.ownPower, + required this.onChanged, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pt = PyramidTheme.of(context); + return PopupMenuButton<_MemberAction>( + icon: Icon(Icons.more_vert, color: pt.fgDim, size: 18), + color: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rBase)), + itemBuilder: (_) => [ + if (ownPower >= _kPowerAdmin && member.powerLevel < _kPowerMod) + const PopupMenuItem( + value: _MemberAction.makeMod, + child: Text('Zum Moderator machen'), + ), + if (ownPower >= _kPowerAdmin && member.powerLevel == _kPowerMod) + const PopupMenuItem( + value: _MemberAction.removeMod, + child: Text('Moderator-Rechte entziehen'), + ), + if (ownPower >= _kPowerAdmin && member.powerLevel < _kPowerAdmin) + const PopupMenuItem( + value: _MemberAction.makeAdmin, + child: Text('Zum Admin machen'), + ), + const PopupMenuItem( + value: _MemberAction.kick, + child: Text('Kicken', style: TextStyle(color: Color(0xFFF59E0B))), + ), + const PopupMenuItem( + value: _MemberAction.ban, + child: Text('Bannen', style: TextStyle(color: Color(0xFFED4245))), + ), + ], + onSelected: (action) => _handleAction(context, action), + ); + } + + Future _handleAction(BuildContext context, _MemberAction action) async { + switch (action) { + case _MemberAction.makeMod: + await _setPower(context, _kPowerMod); + case _MemberAction.removeMod: + await _setPower(context, _kPowerMember); + case _MemberAction.makeAdmin: + _confirmAdmin(context); + case _MemberAction.kick: + _confirmKick(context); + case _MemberAction.ban: + _confirmBan(context); + } + } + + Future _setPower(BuildContext context, int level) async { + try { + // NICHT space.setPower() — das merged nur mit dem lokalen Cache und + // kann die users-Map des Servers zerstören (Admin-Aussperrung). + await updatePowerLevelsSafely(space, (content) { + (content['users'] as Map)[member.id] = level; + }); + onChanged(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString()))); + } + } + } + + void _confirmAdmin(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Admin machen?'), + content: Text( + '${member.displayName ?? member.id} zum Admin befördern? ' + 'Admin-Rechte können nicht einfach entzogen werden.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Abbrechen')), + TextButton( + onPressed: () async { + Navigator.pop(context); + await _setPower(context, _kPowerAdmin); + }, + child: const Text('Admin machen', + style: TextStyle(color: Color(0xFFED4245))), + ), + ], + ), + ); + } + + void _confirmKick(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Mitglied kicken?'), + content: Text('${member.displayName ?? member.id} aus dem Space entfernen?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Abbrechen')), + TextButton( + onPressed: () async { + Navigator.pop(context); + try { + await space.kick(member.id); + onChanged(); + } catch (_) {} + }, + child: const Text('Kicken', + style: TextStyle(color: Color(0xFFF59E0B))), + ), + ], + ), + ); + } + + void _confirmBan(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Mitglied bannen?'), + content: Text( + '${member.displayName ?? member.id} dauerhaft aus dem Space ausschließen?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Abbrechen')), + TextButton( + onPressed: () async { + Navigator.pop(context); + try { + await space.ban(member.id); + onChanged(); + } catch (_) {} + }, + child: const Text('Bannen', + style: TextStyle(color: Color(0xFFED4245))), + ), + ], + ), + ); + } +} + +enum _MemberAction { makeMod, removeMod, makeAdmin, kick, ban } + +// ─── Permissions Tab ────────────────────────────────────────────────────────── + +class _PermissionsTab extends ConsumerStatefulWidget { + final Room space; + const _PermissionsTab({required this.space}); + + @override + ConsumerState<_PermissionsTab> createState() => _PermissionsTabState(); +} + +class _PermissionsTabState extends ConsumerState<_PermissionsTab> { + bool _saving = false; + String? _status; + + // Current power level values (loaded from room state) + late Map _levels; + + static const _defaults = { + 'events_default': 0, + 'state_default': 50, + 'invite': 0, + 'kick': 50, + 'ban': 50, + 'redact': 50, + }; + + static const _labels = { + 'events_default': 'Nachrichten senden', + 'state_default': 'Raum-Einstellungen ändern', + 'invite': 'Mitglieder einladen', + 'kick': 'Mitglieder kicken', + 'ban': 'Mitglieder bannen', + 'redact': 'Nachrichten löschen', + }; + + @override + void initState() { + super.initState(); + _load(); + } + + void _load() { + final content = widget.space + .getState(EventTypes.RoomPowerLevels) + ?.content ?? + {}; + _levels = { + for (final key in _defaults.keys) + key: (content[key] as int?) ?? _defaults[key]!, + }; + } + + Future _save() async { + setState(() { _saving = true; _status = null; }); + try { + // Server-Stand holen und nur die geänderten Schlüssel mergen — der + // lokale Cache kann unvollständig sein und würde users/events wipen. + await updatePowerLevelsSafely(widget.space, (content) { + for (final entry in _levels.entries) { + content[entry.key] = entry.value; + } + }); + setState(() => _status = 'Gespeichert'); + } catch (e) { + final msg = e is MatrixException + ? '${e.errcode}: ${e.errorMessage}' + : e.toString().split('\n').first; + setState(() => _status = 'Fehler: $msg'); + } finally { + setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final ownPower = widget.space.ownPowerLevel; + final canEdit = ownPower >= _kPowerAdmin; + + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Mindestberechtigungen', + style: TextStyle( + color: pt.fgDim, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.8)), + const SizedBox(height: 4), + Text( + 'Lege fest, welche Stufe ein Mitglied mindestens haben muss, um diese Aktion auszuführen.', + style: TextStyle(color: pt.fgDim, fontSize: 12)), + const SizedBox(height: 16), + ...(_labels.entries.map((e) => _PermissionRow( + label: e.value, + value: _levels[e.key]!, + enabled: canEdit, + pt: pt, + onChanged: (v) => setState(() => _levels[e.key] = v), + ))), + if (canEdit) ...[ + const SizedBox(height: 20), + if (_status != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_status!, + style: TextStyle( + color: _status!.startsWith('F') + ? const Color(0xFFED4245) + : const Color(0xFF57F287), + fontSize: 12)), + ), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + style: FilledButton.styleFrom( + backgroundColor: pt.accent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rBase)), + ), + child: _saving + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: pt.bg0)) + : const Text('Speichern', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600)), + ), + ), + ], + ], + ), + ); + } +} + +class _PermissionRow extends StatelessWidget { + final String label; + final int value; + final bool enabled; + final PyramidTheme pt; + final ValueChanged onChanged; + + const _PermissionRow({ + required this.label, + required this.value, + required this.enabled, + required this.pt, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Expanded( + child: Text(label, + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500)), + ), + if (enabled) + DropdownButton( + value: value, + dropdownColor: pt.bg2, + underline: const SizedBox.shrink(), + style: TextStyle(color: pt.fg, fontSize: 13), + items: const [ + DropdownMenuItem(value: 0, child: Text('Mitglied (0)')), + DropdownMenuItem(value: 50, child: Text('Moderator (50)')), + DropdownMenuItem(value: 100, child: Text('Admin (100)')), + ], + onChanged: (v) => v != null ? onChanged(v) : null, + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: _powerColor(value, pt).withAlpha(30), + borderRadius: BorderRadius.circular(6), + ), + child: Text(_powerLabel(value), + style: TextStyle( + color: _powerColor(value, pt), + fontSize: 12, + fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } +} + +// ─── Channels Tab ───────────────────────────────────────────────────────────── + +const _kSpaceChild = 'm.space.child'; +const _kSpaceParent = 'm.space.parent'; +const _kVoiceChannelType = 'io.pyramid.voice_channel'; + +class _ChannelsTab extends ConsumerStatefulWidget { + final Room space; + const _ChannelsTab({required this.space}); + + @override + ConsumerState<_ChannelsTab> createState() => _ChannelsTabState(); +} + +class _ChannelsTabState extends ConsumerState<_ChannelsTab> { + bool _busy = false; + + List<({String roomId, Room? room, bool isVoice})> _getChildren(Client client) { + final childStates = widget.space.states[_kSpaceChild] ?? {}; + final result = <({String roomId, Room? room, bool isVoice})>[]; + for (final entry in childStates.entries) { + final content = entry.value.content; + if (content.isEmpty) continue; + final childRoomId = entry.key; + final room = client.getRoomById(childRoomId); + final createContent = room?.getState('m.room.create')?.content; + final isVoice = createContent?['type'] == _kVoiceChannelType; + result.add((roomId: childRoomId, room: room, isVoice: isVoice)); + } + result.sort((a, b) { + final nameA = a.room?.getLocalizedDisplayname() ?? a.roomId; + final nameB = b.room?.getLocalizedDisplayname() ?? b.roomId; + return nameA.compareTo(nameB); + }); + return result; + } + + Future _removeChild(String roomId) async { + final client = ref.read(matrixClientProvider).valueOrNull; + if (client == null) return; + setState(() => _busy = true); + try { + await client.setRoomStateWithKey(widget.space.id, _kSpaceChild, roomId, {}); + if (mounted) setState(() {}); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _addChild(String roomId) async { + final client = ref.read(matrixClientProvider).valueOrNull; + if (client == null) return; + final serverName = client.userID?.split(':').last ?? ''; + try { + await client.setRoomStateWithKey( + widget.space.id, + _kSpaceChild, + roomId, + {'via': [serverName], 'suggested': false}, + ); + // Best-effort: set parent in child room + try { + await client.setRoomStateWithKey( + roomId, + _kSpaceParent, + widget.space.id, + {'via': [serverName], 'canonical': true}, + ); + } catch (_) {} + if (mounted) setState(() {}); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } + } + + Future _createAndAdd({ + required String name, + required bool isVoice, + required Client client, + }) async { + setState(() => _busy = true); + try { + final serverName = client.userID?.split(':').last ?? ''; + final roomId = await client.createRoom( + name: name, + visibility: Visibility.private, + preset: CreateRoomPreset.privateChat, + creationContent: isVoice ? {'type': _kVoiceChannelType} : null, + initialState: [ + StateEvent( + type: 'm.room.join_rules', + content: {'join_rule': 'restricted', 'allow': [ + {'type': 'm.room_membership', 'room_id': widget.space.id} + ]}, + ), + ], + ); + await client.setRoomStateWithKey( + widget.space.id, + _kSpaceChild, + roomId, + {'via': [serverName], 'suggested': false}, + ); + try { + await client.setRoomStateWithKey( + roomId, + _kSpaceParent, + widget.space.id, + {'via': [serverName], 'canonical': true}, + ); + } catch (_) {} + if (mounted) setState(() {}); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + void _showCreateDialog(BuildContext context, Client client) { + showDialog( + context: context, + builder: (_) => _CreateChannelDialog( + onConfirm: (name, isVoice) => + _createAndAdd(name: name, isVoice: isVoice, client: client), + ), + ); + } + + void _showAddExistingDialog(BuildContext context, Client client) { + final childStates = widget.space.states[_kSpaceChild] ?? {}; + final existingIds = { + for (final e in childStates.entries) + if (e.value.content.isNotEmpty) e.key + }; + final available = client.rooms + .where((r) => !r.isSpace && !existingIds.contains(r.id)) + .toList() + ..sort((a, b) => a.getLocalizedDisplayname() + .compareTo(b.getLocalizedDisplayname())); + + showDialog( + context: context, + builder: (_) => _AddExistingDialog( + rooms: available, + client: client, + onConfirm: (roomId) => _addChild(roomId), + ), + ); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final client = ref.watch(matrixClientProvider).valueOrNull; + final ownPower = widget.space.ownPowerLevel; + final canManage = ownPower >= _kPowerMod; + + if (client == null) { + return Center( + child: CircularProgressIndicator(color: pt.accent, strokeWidth: 2)); + } + + final children = _getChildren(client); + + return Column( + children: [ + if (canManage) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.add, size: 16), + label: const Text('Kanal erstellen'), + style: OutlinedButton.styleFrom( + foregroundColor: pt.accent, + side: BorderSide(color: pt.accent), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rSm)), + ), + onPressed: _busy + ? null + : () => _showCreateDialog(context, client), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.link, size: 16), + label: const Text('Raum hinzufügen'), + style: OutlinedButton.styleFrom( + foregroundColor: pt.fgDim, + side: BorderSide(color: pt.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rSm)), + ), + onPressed: _busy + ? null + : () => _showAddExistingDialog(context, client), + ), + ), + ], + ), + ), + if (children.isEmpty) + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.forum_outlined, color: pt.fgDim, size: 40), + const SizedBox(height: 8), + Text('Noch keine Kanäle', + style: TextStyle(color: pt.fgDim, fontSize: 13)), + ], + ), + ), + ) + else + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + itemCount: children.length, + itemBuilder: (context, i) { + final child = children[i]; + final name = child.room?.getLocalizedDisplayname() ?? + child.roomId; + return _ChannelItem( + name: name, + isVoice: child.isVoice, + roomId: child.roomId, + pt: pt, + canRemove: canManage, + onRemove: () => _removeChild(child.roomId), + ); + }, + ), + ), + ], + ); + } +} + +class _ChannelItem extends StatefulWidget { + final String name; + final bool isVoice; + final String roomId; + final PyramidTheme pt; + final bool canRemove; + final VoidCallback onRemove; + + const _ChannelItem({ + required this.name, + required this.isVoice, + required this.roomId, + required this.pt, + required this.canRemove, + required this.onRemove, + }); + + @override + State<_ChannelItem> createState() => _ChannelItemState(); +} + +class _ChannelItemState extends State<_ChannelItem> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 1), + decoration: BoxDecoration( + color: _hovered ? pt.bg2 : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: ListTile( + dense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 0), + leading: Icon( + widget.isVoice + ? Icons.volume_up_outlined + : Icons.tag, + color: pt.fgDim, + size: 18, + ), + title: Text( + widget.name, + style: TextStyle( + color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + widget.isVoice ? 'Sprachkanal' : 'Textkanal', + style: TextStyle(color: pt.fgDim, fontSize: 11), + ), + trailing: widget.canRemove && _hovered + ? IconButton( + icon: Icon(Icons.link_off, + color: const Color(0xFFED4245), size: 18), + tooltip: 'Aus Space entfernen', + onPressed: widget.onRemove, + ) + : null, + ), + ), + ); + } +} + +class _CreateChannelDialog extends StatefulWidget { + final void Function(String name, bool isVoice) onConfirm; + const _CreateChannelDialog({required this.onConfirm}); + + @override + State<_CreateChannelDialog> createState() => _CreateChannelDialogState(); +} + +class _CreateChannelDialogState extends State<_CreateChannelDialog> { + final _ctrl = TextEditingController(); + bool _isVoice = false; + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return AlertDialog( + backgroundColor: pt.bg1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rLg)), + title: Text('Neuen Kanal erstellen', + style: TextStyle(color: pt.fg, fontWeight: FontWeight.w700)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel('Name', pt), + const SizedBox(height: 6), + _Field(controller: _ctrl, hint: 'kanal-name', pt: pt), + const SizedBox(height: 16), + _SectionLabel('Typ', pt), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => setState(() => _isVoice = false), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: !_isVoice ? pt.accent.withAlpha(40) : pt.bg0, + border: Border.all( + color: !_isVoice ? pt.accent : pt.border), + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.tag, + color: !_isVoice ? pt.accent : pt.fgDim, + size: 16), + const SizedBox(width: 6), + Text('Text', + style: TextStyle( + color: !_isVoice ? pt.accent : pt.fgDim, + fontSize: 13, + fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: GestureDetector( + onTap: () => setState(() => _isVoice = true), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: _isVoice ? pt.accent.withAlpha(40) : pt.bg0, + border: Border.all( + color: _isVoice ? pt.accent : pt.border), + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.volume_up_outlined, + color: _isVoice ? pt.accent : pt.fgDim, + size: 16), + const SizedBox(width: 6), + Text('Sprache', + style: TextStyle( + color: _isVoice ? pt.accent : pt.fgDim, + fontSize: 13, + fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Abbrechen')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: pt.accent), + onPressed: () { + final name = _ctrl.text.trim(); + if (name.isEmpty) return; + Navigator.pop(context); + widget.onConfirm(name, _isVoice); + }, + child: const Text('Erstellen', + style: TextStyle(color: Colors.white)), + ), + ], + ); + } +} + +class _AddExistingDialog extends StatefulWidget { + final List rooms; + final Client client; + final void Function(String roomId) onConfirm; + + const _AddExistingDialog({ + required this.rooms, + required this.client, + required this.onConfirm, + }); + + @override + State<_AddExistingDialog> createState() => _AddExistingDialogState(); +} + +class _AddExistingDialogState extends State<_AddExistingDialog> { + String _filter = ''; + final _ctrl = TextEditingController(); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final filtered = _filter.isEmpty + ? widget.rooms + : widget.rooms + .where((r) => r + .getLocalizedDisplayname() + .toLowerCase() + .contains(_filter.toLowerCase())) + .toList(); + + return Dialog( + backgroundColor: pt.bg1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rLg)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 520), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text('Raum hinzufügen', + style: TextStyle( + color: pt.fg, + fontSize: 17, + fontWeight: FontWeight.w700)), + ), + IconButton( + icon: Icon(Icons.close, color: pt.fgDim, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 12), + _SearchField( + controller: _ctrl, + hint: 'Raum suchen…', + pt: pt, + onChanged: (v) => setState(() => _filter = v), + ), + ], + ), + ), + const Divider(height: 1), + if (filtered.isEmpty) + Expanded( + child: Center( + child: Text('Keine Räume gefunden', + style: TextStyle(color: pt.fgDim, fontSize: 13)), + ), + ) + else + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + itemCount: filtered.length, + itemBuilder: (context, i) { + final room = filtered[i]; + final name = room.getLocalizedDisplayname(); + final letter = + name.isNotEmpty ? name[0].toUpperCase() : '?'; + final avatarUri = room.avatar; + final createContent = + room.getState('m.room.create')?.content; + final isVoice = + createContent?['type'] == _kVoiceChannelType; + return ListTile( + dense: true, + leading: avatarUri != null + ? MxcAvatar( + mxcUri: avatarUri, + client: widget.client, + size: 32, + borderRadius: BorderRadius.circular(pt.rSm), + placeholder: (_) => _InitialAvatar( + letter: letter, pt: pt, size: 32), + ) + : _InitialAvatar(letter: letter, pt: pt, size: 32), + title: Text(name, + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis), + subtitle: Text( + isVoice ? 'Sprachkanal' : 'Textkanal', + style: TextStyle(color: pt.fgDim, fontSize: 11), + ), + trailing: + Icon(isVoice ? Icons.volume_up_outlined : Icons.tag, + color: pt.fgDim, size: 16), + onTap: () { + Navigator.pop(context); + widget.onConfirm(room.id); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +class _SectionLabel extends StatelessWidget { + final String text; + final PyramidTheme pt; + const _SectionLabel(this.text, this.pt); + + @override + Widget build(BuildContext context) { + return Text(text, + style: TextStyle( + color: pt.fgDim, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.8)); + } +} + +class _Field extends StatelessWidget { + final TextEditingController controller; + final String hint; + final int maxLines; + final PyramidTheme pt; + + const _Field({ + required this.controller, + required this.hint, + this.maxLines = 1, + required this.pt, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + maxLines: maxLines, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: pt.fgDim), + filled: true, + fillColor: pt.bg0, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.accent), + ), + ), + ); + } +} + +class _SearchField extends StatelessWidget { + final TextEditingController controller; + final String hint; + final PyramidTheme pt; + final ValueChanged onChanged; + + const _SearchField({ + required this.controller, + required this.hint, + required this.pt, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + onChanged: onChanged, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: pt.fgDim), + prefixIcon: Icon(Icons.search, color: pt.fgDim, size: 18), + filled: true, + fillColor: pt.bg0, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rSm), + borderSide: BorderSide(color: pt.accent), + ), + ), + ); + } +} + +// ─── Avatar Editor ─────────────────────────────────────────────────────────── + +class _AvatarEditor extends StatelessWidget { + final Room space; + final Uint8List? pendingAvatar; + final bool canEdit; + final VoidCallback onPick; + final VoidCallback onRemove; + final PyramidTheme pt; + final dynamic client; + + const _AvatarEditor({ + required this.space, + required this.pendingAvatar, + required this.canEdit, + required this.onPick, + required this.onRemove, + required this.pt, + required this.client, + }); + + @override + Widget build(BuildContext context) { + final letter = space.getLocalizedDisplayname().isNotEmpty + ? space.getLocalizedDisplayname()[0].toUpperCase() + : '?'; + final avatarUri = space.avatar; + final hasAvatar = pendingAvatar != null || avatarUri != null; + + Widget avatarWidget; + if (pendingAvatar != null) { + avatarWidget = Image.memory(pendingAvatar!, fit: BoxFit.cover, + width: 64, height: 64); + } else if (avatarUri != null && client != null) { + avatarWidget = MxcAvatar( + mxcUri: avatarUri, + client: client, + size: 64, + borderRadius: BorderRadius.circular(32), + placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 64), + ); + } else { + avatarWidget = _InitialAvatar(letter: letter, pt: pt, size: 64); + } + + return Row( + children: [ + GestureDetector( + onTap: canEdit ? onPick : null, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: pt.border), + ), + clipBehavior: Clip.hardEdge, + child: avatarWidget, + ), + if (canEdit) + Positioned( + right: -2, + bottom: -2, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: pt.accent, + shape: BoxShape.circle, + border: Border.all(color: pt.bg1, width: 2), + ), + child: const Icon(Icons.edit, size: 10, color: Colors.white), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + canEdit ? 'Klicken zum Ändern' : 'Serverbild', + style: TextStyle(color: pt.fg, fontSize: 13, + fontWeight: FontWeight.w500), + ), + if (canEdit && hasAvatar) + GestureDetector( + onTap: onRemove, + child: Text('Entfernen', + style: TextStyle( + color: const Color(0xFFED4245), fontSize: 12)), + ), + ], + ), + ], + ); + } +} + +class _InitialAvatar extends StatelessWidget { + final String letter; + final PyramidTheme pt; + final double size; + const _InitialAvatar( + {required this.letter, required this.pt, required this.size}); + + @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.4, + fontWeight: FontWeight.w700)), + ), + ); + } +} diff --git a/lib/features/spaces/spaces_rail.dart b/lib/features/spaces/spaces_rail.dart new file mode 100644 index 0000000..19d0eb7 --- /dev/null +++ b/lib/features/spaces/spaces_rail.dart @@ -0,0 +1,535 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/features/rooms/rooms_provider.dart'; +import 'package:pyramid/widgets/create_join_dialog.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; +import 'package:pyramid/widgets/pyramid_logo.dart'; + +class SpacesRail extends ConsumerWidget { + const SpacesRail({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pt = PyramidTheme.of(context); + final spaces = ref.watch(spacesProvider).valueOrNull ?? []; + final activeSpaceId = ref.watch(activeSpaceIdProvider); + final forceJoined = ref.watch(forceJoinedSpacesProvider); + + final dmUnread = ref.watch(dmUnreadCountProvider).valueOrNull ?? 0; + + void selectSpace(String? id) { + // Save current room for the space we're leaving + final currentRoom = ref.read(activeRoomIdProvider); + final currentSpace = ref.read(activeSpaceIdProvider); + if (currentRoom != null) { + final updated = Map.from(ref.read(lastRoomPerSpaceProvider)); + updated[currentSpace] = currentRoom; + ref.read(lastRoomPerSpaceProvider.notifier).state = updated; + } + // Switch space and restore last visited room for the new space + ref.read(activeSpaceIdProvider.notifier).state = id; + ref.read(activeRoomIdProvider.notifier).state = + ref.read(lastRoomPerSpaceProvider)[id]; + final scaffold = Scaffold.maybeOf(context); + if (scaffold != null && scaffold.isDrawerOpen) { + scaffold.closeDrawer(); + } + } + + return Container( + width: 72, + color: pt.bg0, + child: Column( + children: [ + const SizedBox(height: 12), + _HomeButton( + pt: pt, + active: activeSpaceId == 'dms' || activeSpaceId == null, + unreadCount: dmUnread, + onTap: () => selectSpace('dms'), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Container( + width: 32, + height: 2, + decoration: BoxDecoration( + color: pt.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Expanded( + child: ListView( + padding: EdgeInsets.zero, + children: [ + _VirtualSpaceItem( + id: 'rooms', + label: 'R', + color: const Color(0xFFF59E0B), + name: 'Rooms', + active: activeSpaceId == 'rooms', + unread: 0, + pt: pt, + onTap: () => selectSpace('rooms'), + ), + ...spaces.map((space) => Padding( + padding: const EdgeInsets.only(top: 6), + child: _SpaceItem( + space: space, + active: activeSpaceId == space.id, + isInvite: space.membership == Membership.invite && + !forceJoined.contains(space.id), + pt: pt, + onTap: () => selectSpace(space.id), + ), + )), + const SizedBox(height: 8), + _AddButton(pt: pt), + const SizedBox(height: 8), + ], + ), + ), + const SizedBox(height: 8), + _SettingsButton(pt: pt), + const SizedBox(height: 12), + ], + ), + ); + } +} + +class _SettingsButton extends ConsumerWidget { + final PyramidTheme pt; + const _SettingsButton({required this.pt}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Center( + child: IconButton( + icon: Icon(Icons.settings_outlined, color: pt.fgDim), + onPressed: () { + ref.read(activeModalProvider.notifier).state = ModalKind.settings; + final scaffold = Scaffold.maybeOf(context); + if (scaffold != null && scaffold.isDrawerOpen) { + scaffold.closeDrawer(); + } + }, + ), + ); + } +} + +class _HomeButton extends StatefulWidget { + final PyramidTheme pt; + final bool active; + final int unreadCount; + final VoidCallback onTap; + + const _HomeButton({ + required this.pt, + required this.active, + required this.unreadCount, + required this.onTap, + }); + + @override + State<_HomeButton> createState() => _HomeButtonState(); +} + +class _HomeButtonState extends State<_HomeButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Stack( + clipBehavior: Clip.none, + children: [ + if (widget.active) + Positioned( + left: -12, + top: 10, + child: Container( + width: 4, + height: 28, + decoration: BoxDecoration( + color: pt.accent, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(3), + bottomRight: Radius.circular(3), + ), + ), + ), + ), + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutBack, + transform: _hovered + ? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05)) + : Matrix4.identity(), + child: Tooltip( + message: 'Direct Messages', + waitDuration: const Duration(milliseconds: 400), + preferBelow: false, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [pt.accent, Color.fromARGB(255, + (pt.accent.r * 255 * 0.85).round().clamp(0, 255), + (pt.accent.g * 255 * 0.85).round().clamp(0, 255), + (pt.accent.b * 255 * 0.7).round().clamp(0, 255), + )], + ), + borderRadius: BorderRadius.circular( + widget.active ? pt.rLg * 0.6 : pt.rLg, + ), + boxShadow: widget.active + ? [BoxShadow(color: pt.accentGlow, blurRadius: 16, offset: const Offset(0, 4))] + : null, + ), + child: Center(child: PyramidLogo(size: 28, color: Colors.white)), + ), + if (widget.unreadCount > 0) + Positioned( + right: -4, + top: -4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + decoration: BoxDecoration( + color: pt.danger, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: pt.bg0, width: 1.5), + ), + child: Center( + child: Text( + widget.unreadCount > 99 ? '99+' : '${widget.unreadCount}', + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w700), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ); + } +} + +class _VirtualSpaceItem extends StatefulWidget { + final String id; + final String label; + final Color color; + final String name; + final bool active; + final int unread; + final PyramidTheme pt; + final VoidCallback onTap; + + const _VirtualSpaceItem({ + required this.id, + required this.label, + required this.color, + required this.name, + required this.active, + required this.unread, + required this.pt, + required this.onTap, + }); + + @override + State<_VirtualSpaceItem> createState() => _VirtualSpaceItemState(); +} + +class _VirtualSpaceItemState extends State<_VirtualSpaceItem> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final active = widget.active; + final radius = active + ? pt.rLg * 0.6 + : _hovered ? pt.rLg * 0.7 : pt.rLg; + + return Center( + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: Stack( + clipBehavior: Clip.none, + children: [ + // Active indicator + if (active) + Positioned( + left: -18, + top: 10, + child: Container( + width: 4, + height: 28, + decoration: BoxDecoration( + color: pt.accent, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(3), + bottomRight: Radius.circular(3), + ), + ), + ), + ), + Tooltip( + message: widget.name, + waitDuration: const Duration(milliseconds: 400), + preferBelow: false, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutBack, + width: 48, + height: 48, + decoration: BoxDecoration( + color: _hovered ? pt.bg3 : pt.bg2, + borderRadius: BorderRadius.circular(radius), + ), + child: Center( + child: PyramidGlyph( + size: 30, + color: widget.color, + label: widget.label, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SpaceItem extends ConsumerStatefulWidget { + final Room space; + final bool active; + final bool isInvite; + final PyramidTheme pt; + final VoidCallback onTap; + + const _SpaceItem({ + required this.space, + required this.active, + this.isInvite = false, + required this.pt, + required this.onTap, + }); + + @override + ConsumerState<_SpaceItem> createState() => _SpaceItemState(); +} + +class _SpaceItemState extends ConsumerState<_SpaceItem> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final active = widget.active; + final name = widget.space.getLocalizedDisplayname(); + final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; + final radius = active ? pt.rLg * 0.6 : _hovered ? pt.rLg * 0.7 : pt.rLg; + final client = ref.watch(matrixClientProvider).valueOrNull; + final avatarUri = widget.space.avatar; + + Widget avatarChild; + if (client != null && avatarUri != null) { + avatarChild = MxcAvatar( + mxcUri: avatarUri, + client: client, + size: 48, + borderRadius: BorderRadius.circular(radius), + placeholder: (_) => _SpaceInitial(letter: letter, pt: pt, radius: radius), + ); + } else { + avatarChild = _SpaceInitial(letter: letter, pt: pt, radius: radius); + } + + return Center( + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + onSecondaryTap: widget.isInvite + ? null + : () => showDialog( + context: context, + builder: (_) => SpaceEditDialog(space: widget.space), + ), + onLongPress: widget.isInvite + ? null + : () => showDialog( + context: context, + builder: (_) => SpaceEditDialog(space: widget.space), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + if (active) + Positioned( + left: -18, + top: 10, + child: Container( + width: 4, + height: 28, + decoration: BoxDecoration( + color: pt.accent, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(3), + bottomRight: Radius.circular(3), + ), + ), + ), + ), + Tooltip( + message: name, + waitDuration: const Duration(milliseconds: 400), + preferBelow: false, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutBack, + width: 48, + height: 48, + decoration: BoxDecoration( + color: _hovered ? pt.bg3 : pt.bg2, + borderRadius: BorderRadius.circular(radius), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: avatarChild, + ), + ), + ), + if (widget.isInvite) + Positioned( + right: -2, + top: -2, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: pt.accent, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: pt.bg0, width: 1.5), + ), + child: const Text( + 'NEU', + style: TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.w800, + letterSpacing: 0.3), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SpaceInitial extends StatelessWidget { + final String letter; + final PyramidTheme pt; + final double radius; + const _SpaceInitial({required this.letter, required this.pt, required this.radius}); + + @override + Widget build(BuildContext context) { + return Center( + child: Text(letter, style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + ); + } +} + +class _AddButton extends StatefulWidget { + final PyramidTheme pt; + const _AddButton({required this.pt}); + + @override + State<_AddButton> createState() => _AddButtonState(); +} + +class _AddButtonState extends State<_AddButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Center( + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: () => showDialog( + context: context, + builder: (_) => const CreateJoinDialog(), + ), + child: Tooltip( + message: 'Create · Join · Discover', + waitDuration: const Duration(milliseconds: 400), + preferBelow: false, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutBack, + width: 48, + height: 48, + decoration: BoxDecoration( + color: _hovered ? pt.accentSoft : pt.bg2, + borderRadius: BorderRadius.circular(_hovered ? pt.rLg * 0.7 : pt.rLg), + border: Border.all( + color: _hovered ? pt.accent : pt.borderStrong, + width: 1.5, + style: BorderStyle.solid, + ), + ), + child: Center( + child: AnimatedRotation( + turns: _hovered ? 0.25 : 0, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutBack, + child: Icon(Icons.add, color: pt.accent, size: 22), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/layout/app_shell.dart b/lib/layout/app_shell.dart new file mode 100644 index 0000000..538ddd0 --- /dev/null +++ b/lib/layout/app_shell.dart @@ -0,0 +1,1073 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/encryption/utils/key_verification.dart' show KeyVerification, KeyVerificationEmoji, KeyVerificationState; +import 'package:matrix/matrix.dart'; +import 'package:pretty_qr_code/pretty_qr_code.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/core/voip_manager.dart'; +import 'package:pyramid/features/auth/bootstrap_dialog.dart'; +import 'package:pyramid/features/call/voice_channel.dart'; +import 'package:pyramid/features/chat/chat_view.dart'; +import 'package:pyramid/features/members/members_panel.dart'; +import 'package:pyramid/features/rooms/rooms_panel.dart'; +import 'package:pyramid/features/spaces/spaces_rail.dart'; +import 'package:pyramid/widgets/search_modal.dart'; +import 'package:pyramid/widgets/settings_modal.dart'; +import 'package:pyramid/widgets/share_target_dialog.dart'; +import 'package:pyramid/core/notification_service.dart'; +import 'package:pyramid/widgets/update_banner.dart'; + +class AppShell extends ConsumerStatefulWidget { + const AppShell({super.key}); + + @override + ConsumerState createState() => _AppShellState(); +} + +class _AppShellState extends ConsumerState + with WidgetsBindingObserver { + double _sidebarWidth = 260; + bool _bootstrapTriggered = false; + // Flankenerkennung für die Auto-Switch-Listener (siehe build). + bool _wasVoipConnected = false; + bool _wasLiveKitActive = false; + StreamSubscription? _verificationSub; + Timer? _presenceTimer; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + _triggerBootstrap(); + _subscribeVerification(); + initNotifications(ref); + _startPresenceHeartbeat(); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _verificationSub?.cancel(); + _presenceTimer?.cancel(); + super.dispose(); + } + + /// Sends `setPresence(online)` every 55 seconds while the app is active. + /// This prevents the server from marking us offline due to inactivity timeout. + void _startPresenceHeartbeat() async { + _presenceTimer?.cancel(); + final client = await ref.read(matrixClientProvider.future); + if (!mounted) return; + _sendPresence(client, true); + _presenceTimer = Timer.periodic(const Duration(seconds: 55), (_) { + _sendPresence(client, true); + }); + } + + void _sendPresence(Client client, bool online) { + client + .setPresence( + client.userID!, + online ? PresenceType.online : PresenceType.unavailable, + ) + .catchError((_) {}); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + // Update focus state immediately — used by notification suppression logic. + ref.read(windowFocusedProvider.notifier).state = + state == AppLifecycleState.resumed; + ref.read(matrixClientProvider.future).then((client) { + if (!mounted) return; + if (state == AppLifecycleState.resumed) { + _sendPresence(client, true); + // Restart heartbeat timer after coming back from background. + _presenceTimer?.cancel(); + _presenceTimer = Timer.periodic(const Duration(seconds: 55), (_) { + _sendPresence(client, true); + }); + // Advance the notification watch time so catch-up events from the initial + // sync (already shown by the FCM background handler) are not shown again. + resetNotificationStartTime(); + // Send any inline reply stored by the background notification handler. + checkPendingReply(ref); + } else if (state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + _presenceTimer?.cancel(); + _sendPresence(client, false); + } + }).catchError((_) {}); + } + + void _triggerBootstrap() async { + if (_bootstrapTriggered) return; + final client = await ref.read(matrixClientProvider.future); + if (client.encryption?.crossSigning.enabled != true) { + if (mounted) { + setState(() => _bootstrapTriggered = true); + showBootstrapDialog(context, client); + } + } + } + + void _subscribeVerification() async { + final client = await ref.read(matrixClientProvider.future); + if (!mounted) return; + _verificationSub = client.onKeyVerificationRequest.stream.listen((request) { + if (!mounted) return; + // Only show dialog if it's in askAccept state (incoming request not yet handled) + if (request.state == KeyVerificationState.askAccept) { + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _IncomingVerificationDialog( + request: request, + pt: PyramidTheme.of(context), + ), + ); + } + }); + } + + void onSidebarResize(double delta) { + setState(() { + _sidebarWidth = (_sidebarWidth + delta).clamp(200.0, 500.0); + }); + } + + void onCloseModal() { + ref.read(activeModalProvider.notifier).state = ModalKind.none; + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + + // Activate notification watcher (no-op after first call) + ref.watch(notificationWatcherProvider); + + // Auto-switch to voice view when call is connected — but only on the + // transition into the connected state. The provider feuert bei jedem + // notifyListeners (u.a. Render-Timer im Sekundentakt); ohne Flankenerkennung + // würde der Nutzer permanent zurück in die Call-UI gezwungen und könnte + // sie nie minimieren. + ref.listen(voipStateProvider, (prev, next) { + final wasConnected = _wasVoipConnected; + final isConnected = next.currentCall != null && + next.currentCall!.state == CallState.kConnected; + _wasVoipConnected = isConnected; + if (isConnected && !wasConnected) { + if (ref.read(viewModeProvider) != ViewMode.voice) { + ref.read(viewModeProvider.notifier).state = ViewMode.voice; + } + } + }); + + ref.listen(callStateProvider, (prev, next) { + // Only auto-switch to voice view for video/audio calls, NOT voice channels + // (voice channels should stay in background with a mini bar) + final wasActive = _wasLiveKitActive; + final isActiveCall = next.isActive && !next.isConnecting && !next.isVoiceChannel; + _wasLiveKitActive = isActiveCall; + if (isActiveCall && !wasActive) { + if (ref.read(viewModeProvider) != ViewMode.voice) { + ref.read(viewModeProvider.notifier).state = ViewMode.voice; + } + } + }); + + // "Teilen nach Pyramid" (Android): geteilte Inhalte → Raum-Picker zeigen. + ref.listen(pendingShareProvider, (prev, next) { + if (next != null && !next.isEmpty) { + ref.read(pendingShareProvider.notifier).state = null; + ShareTargetDialog.show(context, ref, next); + } + }); + + final sidebarWidth = _sidebarWidth; + final railCollapsed = ref.watch(railCollapsedProvider); + final activeRoomId = ref.watch(activeRoomIdProvider); + final viewMode = ref.watch(viewModeProvider); + final activeModal = ref.watch(activeModalProvider); + final membersOpen = ref.watch(membersPanelOpenProvider); + final innerPt = PyramidTheme.of(context); + final voip = ref.watch(voipStateProvider); + + final size = MediaQuery.sizeOf(context); + final isMobile = size.width < 900 || size.height < 500; + + // Auto-close members panel if screen gets too small + if (!isMobile && size.width < 1100 && membersOpen) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(membersPanelOpenProvider.notifier).state = false; + }); + } + + if (isMobile) { + return _MobileShell( + pt: innerPt, + activeRoomId: activeRoomId, + viewMode: viewMode, + activeModal: activeModal, + membersOpen: membersOpen, + onCloseModal: onCloseModal, + ); + } + + return Scaffold( + backgroundColor: pt.bg1, + body: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (activeModal != ModalKind.none) { + onCloseModal(); + } + }, + child: Column( + children: [ + const UpdateBanner(), + const _NotificationBlockedBanner(), + Expanded(child: Stack( + children: [ + Row( + children: [ + // Spaces rail + AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + width: railCollapsed ? 0 : 72, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: const SpacesRail(), + ), + // Rooms panel with resizable divider + SizedBox( + width: sidebarWidth.clamp(200.0, size.width * 0.4), + child: const RoomsPanel(), + ), + _ResizeDivider( + pt: innerPt, + onDrag: onSidebarResize, + ), + // Main content + Expanded( + child: viewMode == ViewMode.voice + ? const VoiceChannelView() + : activeRoomId != null + ? ChatView(roomId: activeRoomId) + : const NoChatSelected(), + ), + // Members panel — only if enough space (min 1100px total width) + if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice && size.width >= 1100) + MembersPanel(roomId: activeRoomId), + ], + ), + // Incoming Call Popup for Desktop + if (voip.currentCall != null && voip.currentCall!.state == CallState.kRinging) + _IncomingCallOverlay(call: voip.currentCall!, voip: voip, pt: pt), + + // Modal overlays + if (activeModal == ModalKind.settings) + SettingsModal(onClose: onCloseModal), + if (activeModal == ModalKind.search) + SearchModal(onClose: onCloseModal), + + // Members panel as overlay if screen is narrow but requested + if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice && size.width < 1100) ...[ + Positioned.fill( + child: GestureDetector( + onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false, + child: Container(color: Colors.black.withAlpha(110)), + ), + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: Container( + decoration: BoxDecoration( + boxShadow: [BoxShadow(color: Colors.black45, blurRadius: 16)], + ), + child: MembersPanel( + roomId: activeRoomId, + onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false, + ), + ), + ), + ], + ], + )), // end Stack + Expanded + ], + ), // end Column + ), + ); + } +} + +class _MobileShell extends ConsumerWidget { + final PyramidTheme pt; + final String? activeRoomId; + final ViewMode viewMode; + final ModalKind activeModal; + final bool membersOpen; + final VoidCallback onCloseModal; + + const _MobileShell({ + required this.pt, + required this.activeRoomId, + required this.viewMode, + required this.activeModal, + required this.membersOpen, + required this.onCloseModal, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final voip = ref.watch(voipStateProvider); + + Widget body; + if (viewMode == ViewMode.voice) { + body = const VoiceChannelView(); + } else if (activeRoomId != null) { + body = ChatView(roomId: activeRoomId!); + } else { + body = const RoomsPanel(); + } + + return PopScope( + canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + if (membersOpen) { + ref.read(membersPanelOpenProvider.notifier).state = false; + } else if (viewMode == ViewMode.voice) { + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + } else if (activeRoomId != null) { + ref.read(activeRoomIdProvider.notifier).state = null; + } + } + }, + child: Scaffold( + backgroundColor: pt.bg0, + drawer: Drawer( + width: 80, + backgroundColor: pt.bg0, + child: const SpacesRail(), + ), + body: Stack( + children: [ + body, + if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[ + // Tap-outside scrim closes the panel + Positioned.fill( + child: GestureDetector( + onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false, + child: Container(color: Colors.black.withAlpha(110)), + ), + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: Builder(builder: (ctx) { + final w = MediaQuery.sizeOf(ctx).width; + return MembersPanel( + roomId: activeRoomId!, + width: (w * 0.82).clamp(240.0, 320.0), + onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false, + ); + }), + ), + ], + if (voip.currentCall != null && voip.currentCall!.state == CallState.kRinging) + _IncomingCallOverlay(call: voip.currentCall!, voip: voip, pt: pt), + if (activeModal == ModalKind.settings) + SettingsModal(onClose: onCloseModal), + if (activeModal == ModalKind.search) + SearchModal(onClose: onCloseModal), + const Positioned( + top: 0, left: 0, right: 0, + child: _NotificationBlockedBanner(), + ), + ], + ), + ), + ); + } +} + +class _NotificationBlockedBanner extends ConsumerWidget { + const _NotificationBlockedBanner(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final blocked = ref.watch(notificationsBlockedProvider); + if (!blocked) return const SizedBox.shrink(); + + return Material( + color: Colors.transparent, + child: Container( + color: const Color(0xFFF5A623), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + const Icon(Icons.notifications_off, size: 16, color: Colors.white), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Benachrichtigungen sind deaktiviert', + style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500), + ), + ), + TextButton( + onPressed: () { + openNotificationSettings(); + ref.read(notificationsBlockedProvider.notifier).state = false; + }, + style: TextButton.styleFrom( + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Einstellungen', style: TextStyle(fontSize: 12, decoration: TextDecoration.underline)), + ), + IconButton( + icon: const Icon(Icons.close, size: 14, color: Colors.white), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + onPressed: () => ref.read(notificationsBlockedProvider.notifier).state = false, + ), + ], + ), + ), + ); + } +} + +class _IncomingCallOverlay extends ConsumerWidget { + final CallSession call; + final PyramidVoipManager voip; + final PyramidTheme pt; + + const _IncomingCallOverlay({ + required this.call, + required this.voip, + required this.pt, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Container( + color: Colors.black.withAlpha(180), + child: Center( + child: Container( + width: 320, + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(pt.rXl), + border: Border.all(color: pt.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: pt.accent, + shape: BoxShape.circle, + ), + child: const Icon(Icons.person_rounded, size: 40, color: Colors.white), + ), + const SizedBox(height: 24), + Text( + 'Eingehender Anruf', + style: TextStyle(color: pt.fgDim, fontSize: 13), + ), + const SizedBox(height: 8), + Text( + call.room.getLocalizedDisplayname(), + style: TextStyle(color: pt.fg, fontSize: 18, fontWeight: FontWeight.w700), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + FloatingActionButton( + heroTag: 'reject_call', + onPressed: () => voip.safeAction(() async => await call.reject()), + backgroundColor: pt.danger, + child: const Icon(Icons.call_end_rounded, color: Colors.white), + ), + FloatingActionButton( + heroTag: 'answer_call', + onPressed: () { + ref.read(viewModeProvider.notifier).state = ViewMode.voice; + voip.safeAction(() async => await call.answer()); + }, + backgroundColor: pt.online, + child: const Icon(Icons.call_rounded, color: Colors.white), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _ResizeDivider extends StatefulWidget { + final PyramidTheme pt; + final ValueChanged onDrag; + + const _ResizeDivider({required this.pt, required this.onDrag}); + + @override + State<_ResizeDivider> createState() => _ResizeDividerState(); +} + +class _ResizeDividerState extends State<_ResizeDivider> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragUpdate: (d) => widget.onDrag(d.delta.dx), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 6, + color: _hovered ? widget.pt.accent.withAlpha(80) : widget.pt.border, + ), + ), + ); + } +} + +class NoChatSelected extends StatelessWidget { + const NoChatSelected({super.key}); + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.chat_bubble_outline_rounded, size: 64, color: pt.fgDim.withAlpha(50)), + const SizedBox(height: 16), + Text( + 'Select a chat to start messaging', + style: TextStyle(color: pt.fgMuted, fontSize: 15), + ), + ], + ), + ); + } +} + +// ─── Incoming Key Verification Dialog ─────────────────────────────────────── + +class _IncomingVerificationDialog extends StatefulWidget { + final KeyVerification request; + final PyramidTheme pt; + + const _IncomingVerificationDialog({ + required this.request, + required this.pt, + }); + + @override + State<_IncomingVerificationDialog> createState() => + _IncomingVerificationDialogState(); +} + +class _IncomingVerificationDialogState + extends State<_IncomingVerificationDialog> { + late KeyVerificationState _state; + + @override + void initState() { + super.initState(); + _state = widget.request.state; + widget.request.onUpdate = () { + if (mounted) setState(() => _state = widget.request.state); + }; + } + + @override + void dispose() { + if (!widget.request.isDone) { + widget.request.cancel('m.user').catchError((_) {}); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final req = widget.request; + + return AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: pt.border)), + contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + title: Row( + children: [ + Icon(Icons.verified_user_outlined, size: 20, color: pt.accent), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Verifikationsanfrage', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + ], + ), + content: SizedBox( + width: 380, + child: _buildContent(pt, req), + ), + actions: _buildActions(pt, req), + ); + } + + Widget _buildContent(PyramidTheme pt, KeyVerification req) { + return switch (_state) { + KeyVerificationState.askAccept => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _VerifInfoRow( + icon: Icons.devices_rounded, + label: (req.deviceId?.isNotEmpty == true) ? req.deviceId! : 'Unbekanntes Gerät', + pt: pt, + ), + const SizedBox(height: 6), + _VerifInfoRow( + icon: Icons.person_outline_rounded, + label: req.userId, + pt: pt, + ), + const SizedBox(height: 12), + Text( + 'Dieses Gerät möchte sich mit deinem Konto verifizieren.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + ], + ), + ), + KeyVerificationState.waitingAccept => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: 16), + Text('Bereite Verifikation vor…', + style: TextStyle(color: pt.fgMuted, fontSize: 13)), + ]), + ), + KeyVerificationState.askChoice => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Wähle eine Verifikationsmethode:', + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: () => req.continueVerification('m.sas.v1').catchError((_) {}), + icon: const Icon(Icons.tag_faces_rounded, size: 18), + label: const Text('Emoji-Verifikation'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: pt.bg3, + foregroundColor: pt.fg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: () => req.continueVerification('m.qr_code.show.v1').catchError((_) {}), + icon: const Icon(Icons.qr_code_rounded, size: 18), + label: const Text('QR-Code anzeigen'), + ), + const SizedBox(height: 8), + ], + ), + ), + KeyVerificationState.confirmQRScan => _QrCodeDisplay(pt: pt, request: req), + KeyVerificationState.showQRSuccess => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _InfoCard( + pt: pt, + icon: Icons.verified_rounded, + color: const Color(0xFF22C55E), + text: 'QR-Code erfolgreich gescannt. Verifikation abgeschlossen.'), + ), + KeyVerificationState.askSas => _SasView(request: req, pt: pt, onClose: () => Navigator.pop(context)), + KeyVerificationState.waitingSas => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: 16), + Text('Warte auf Bestätigung der anderen Seite…', + style: TextStyle(color: pt.fgMuted, fontSize: 13)), + ]), + ), + KeyVerificationState.done => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _InfoCard( + pt: pt, + icon: Icons.verified_rounded, + color: const Color(0xFF22C55E), + text: 'Gerät erfolgreich verifiziert.'), + ), + _ => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _InfoCard( + pt: pt, + icon: Icons.error_outline_rounded, + color: pt.danger, + text: 'Verifizierung fehlgeschlagen oder abgebrochen.'), + ), + }; + } + + List _buildActions(PyramidTheme pt, KeyVerification req) { + if (_state == KeyVerificationState.askAccept) { + return [ + TextButton( + onPressed: () { + req.cancel('m.user').catchError((_) {}); + Navigator.pop(context); + }, + child: Text('Ablehnen', style: TextStyle(color: pt.danger)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => req.acceptVerification().catchError((_) {}), + child: const Text('Annehmen'), + ), + ]; + } + if (_state == KeyVerificationState.confirmQRScan) { + return [ + TextButton( + onPressed: () { + req.cancel('m.user').catchError((_) {}); + Navigator.pop(context); + }, + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF22C55E), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => req.acceptQRScanConfirmation().catchError((_) {}), + child: const Text('Gescannt bestätigen'), + ), + ]; + } + if (_state == KeyVerificationState.done || + _state == KeyVerificationState.showQRSuccess || + _state == KeyVerificationState.error) { + return [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(context), + child: const Text('Schließen'), + ), + ]; + } + if (_state == KeyVerificationState.askSas || _state == KeyVerificationState.askChoice) return []; + return [ + TextButton( + onPressed: () { + req.cancel('m.user').catchError((_) {}); + Navigator.pop(context); + }, + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ]; + } +} + +class _SasView extends StatelessWidget { + final KeyVerification request; + final PyramidTheme pt; + final VoidCallback onClose; + + const _SasView({required this.request, required this.pt, required this.onClose}); + + @override + Widget build(BuildContext context) { + final emojis = request.sasEmojis; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Vergleiche die Emojis auf beiden Geräten', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 20), + if (emojis.isNotEmpty) + Wrap( + spacing: 10, + runSpacing: 10, + alignment: WrapAlignment.center, + children: emojis.map((e) => _EmojiChip(emoji: e, pt: pt)).toList(), + ) + else + Text('Keine Emojis verfügbar', style: TextStyle(color: pt.fgDim, fontSize: 13)), + const SizedBox(height: 24), + Text( + 'Stimmen alle Emojis überein?', + style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: pt.danger, + side: BorderSide(color: pt.danger.withAlpha(120)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: () { + request.rejectSas().catchError((_) {}); + onClose(); + }, + icon: const Icon(Icons.close_rounded, size: 16), + label: const Text('Stimmt nicht'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF22C55E), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: () => request.acceptSas().catchError((_) {}), + icon: const Icon(Icons.check_rounded, size: 16), + label: const Text('Stimmt überein'), + ), + ), + ], + ), + const SizedBox(height: 8), + ], + ), + ); + } +} + +class _EmojiChip extends StatelessWidget { + final KeyVerificationEmoji emoji; + final PyramidTheme pt; + const _EmojiChip({required this.emoji, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + width: 64, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: pt.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(emoji.emoji, style: const TextStyle(fontSize: 24)), + const SizedBox(height: 4), + Text( + emoji.name, + style: TextStyle(color: pt.fgMuted, fontSize: 9), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} + +class _VerifInfoRow extends StatelessWidget { + final IconData icon; + final String label; + final PyramidTheme pt; + const _VerifInfoRow({required this.icon, required this.label, required this.pt}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 14, color: pt.fgDim), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: TextStyle(color: pt.fg, fontSize: 12, fontFamily: 'monospace'), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + +class _InfoCard extends StatelessWidget { + final PyramidTheme pt; + final IconData icon; + final Color color; + final String text; + const _InfoCard({required this.pt, required this.icon, required this.color, required this.text}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: pt.border), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 13))), + ], + ), + ); + } +} + +class _QrCodeDisplay extends StatelessWidget { + final PyramidTheme pt; + final KeyVerification request; + + const _QrCodeDisplay({required this.pt, required this.request}); + + @override + Widget build(BuildContext context) { + final qrCode = request.qrCode; + if (qrCode == null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: 12), + Text('QR-Code wird generiert…', style: TextStyle(color: pt.fgMuted, fontSize: 13)), + ], + ), + ); + } + + try { + final rawBytes = Uint8List.fromList(qrCode.qrDataRawBytes.toList()); + final qr = QrCode.fromUint8List(data: rawBytes, errorCorrectLevel: QrErrorCorrectLevel.L); + final qrImage = QrImage(qr); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Scanne diesen QR-Code mit dem anderen Gerät', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 16), + Center( + child: Container( + width: 220, + height: 220, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: PrettyQrView(qrImage: qrImage), + ), + ), + const SizedBox(height: 12), + Text( + 'Sobald das andere Gerät den Code gescannt hat, tippe auf „Gescannt bestätigen".', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgDim, fontSize: 12), + ), + const SizedBox(height: 8), + ], + ), + ); + } catch (_) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _InfoCard( + pt: pt, + icon: Icons.error_outline_rounded, + color: pt.danger, + text: 'QR-Code konnte nicht generiert werden.', + ), + ); + } + } +} diff --git a/lib/layout/panel_layout.dart b/lib/layout/panel_layout.dart deleted file mode 100644 index 8975162..0000000 --- a/lib/layout/panel_layout.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:pyramid/features/rooms/rooms_page.dart'; - -final sidebarWidthProvider = StateProvider((ref) => 280); - -class PanelLayout extends ConsumerWidget { - final Widget mainContent; - const PanelLayout({super.key, required this.mainContent}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final sidebarWidth = ref.watch(sidebarWidthProvider); - - return Row( - children: [ - SizedBox( - width: sidebarWidth, - child: const RoomsPage(), - ), - _ResizeDivider( - onDrag: (delta) { - final notifier = ref.read(sidebarWidthProvider.notifier); - notifier.state = - (notifier.state + delta).clamp(180.0, 480.0); - }, - ), - Expanded(child: mainContent), - ], - ); - } -} - -class _ResizeDivider extends StatelessWidget { - final ValueChanged onDrag; - const _ResizeDivider({required this.onDrag}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragUpdate: (d) => onDrag(d.delta.dx), - child: MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - child: Container( - width: 6, - color: Theme.of(context).dividerColor, - ), - ), - ); - } -} diff --git a/lib/layout/shell_page.dart b/lib/layout/shell_page.dart deleted file mode 100644 index ae01d3b..0000000 --- a/lib/layout/shell_page.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:pyramid/layout/panel_layout.dart'; - -class ShellPage extends ConsumerWidget { - final Widget child; - const ShellPage({super.key, required this.child}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isWide = MediaQuery.sizeOf(context).width >= 700; - - return Scaffold( - body: isWide - ? PanelLayout(mainContent: child) - : child, - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index e2067a2..850a682 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,41 @@ +import 'dart:io'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; +import 'package:media_kit/media_kit.dart'; import 'package:pyramid/core/app.dart'; +import 'package:pyramid/core/background_push.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/features/auth/login_notifier.dart'; +import 'package:pyramid/widgets/pyramid_loader.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); + + if (Platform.isAndroid) { + // Firebase must be initialised first, then the background handler registered. + try { await Firebase.initializeApp(); } catch (_) {} + FirebaseMessaging.onBackgroundMessage(handleBackgroundMessage); + // Store the background-engine callback handle so PushService.kt / + // ReplyReceiver can decrypt + send encrypted replies when the app is killed. + await registerBgEngineHandle(); + } + + // Catch unhandled async exceptions to prevent silent crashes + FlutterError.onError = (details) { + FlutterError.presentError(details); + }; + + await vod.init(); + + // Hide status bar and navigation bar for immersive feel + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + runApp(const ProviderScope(child: PyramidBootstrap())); } @@ -18,7 +48,7 @@ class PyramidBootstrap extends ConsumerWidget { return clientAsync.when( loading: () => const MaterialApp( - home: Scaffold(body: Center(child: CircularProgressIndicator())), + home: Scaffold(body: Center(child: PyramidLoader(size: 80))), ), error: (e, _) => MaterialApp( home: Scaffold(body: Center(child: Text('Fehler: $e'))), diff --git a/lib/utils/clipboard_image.dart b/lib/utils/clipboard_image.dart new file mode 100644 index 0000000..df00b9d --- /dev/null +++ b/lib/utils/clipboard_image.dart @@ -0,0 +1,182 @@ +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; +import 'package:image/image.dart' as img; + +// ── Windows constants ──────────────────────────────────────────────────────── + +const _CF_DIB = 8; +const _CF_DIBV4 = 20; + +// ── Native function typedefs ───────────────────────────────────────────────── + +typedef _OpenClipboardNative = Int32 Function(IntPtr hwnd); +typedef _OpenClipboard = int Function(int hwnd); + +typedef _CloseClipboardNative = Int32 Function(); +typedef _CloseClipboard = int Function(); + +typedef _IsFormatAvailableNative = Int32 Function(Uint32 format); +typedef _IsFormatAvailable = int Function(int format); + +typedef _GetClipboardDataNative = IntPtr Function(Uint32 format); +typedef _GetClipboardData = int Function(int format); + +typedef _GlobalSizeNative = IntPtr Function(IntPtr hMem); +typedef _GlobalSize = int Function(int hMem); + +typedef _GlobalLockNative = Pointer Function(IntPtr hMem); +typedef _GlobalLock = Pointer Function(int hMem); + +typedef _GlobalUnlockNative = Int32 Function(IntPtr hMem); +typedef _GlobalUnlock = int Function(int hMem); + +typedef _RegisterFormatNative = Uint32 Function(Pointer name); +typedef _RegisterFormat = int Function(Pointer name); + +// ── Public API ─────────────────────────────────────────────────────────────── + +/// Returns image bytes (PNG) from the Windows clipboard, or null if there is +/// no image or the platform is not Windows. +/// +/// Checks the following formats in order: +/// 1. "PNG" – custom registered format used by browsers / screenshot tools +/// 2. CF_DIBV4 / CF_DIB – Device-Independent Bitmap (Win+Shift+S, etc.) +Uint8List? getClipboardImageBytes() { + if (!Platform.isWindows) return null; + try { + return _readClipboardImage(); + } catch (_) { + return null; + } +} + +// ── Implementation ─────────────────────────────────────────────────────────── + +Uint8List? _readClipboardImage() { + final user32 = DynamicLibrary.open('user32.dll'); + final kernel32 = DynamicLibrary.open('kernel32.dll'); + + final openClipboard = + user32.lookupFunction<_OpenClipboardNative, _OpenClipboard>('OpenClipboard'); + final closeClipboard = + user32.lookupFunction<_CloseClipboardNative, _CloseClipboard>('CloseClipboard'); + final isFormatAvailable = + user32.lookupFunction<_IsFormatAvailableNative, _IsFormatAvailable>( + 'IsClipboardFormatAvailable'); + final getClipboardData = + user32.lookupFunction<_GetClipboardDataNative, _GetClipboardData>('GetClipboardData'); + final registerFormat = + user32.lookupFunction<_RegisterFormatNative, _RegisterFormat>( + 'RegisterClipboardFormatW'); + final globalSize = + kernel32.lookupFunction<_GlobalSizeNative, _GlobalSize>('GlobalSize'); + final globalLock = + kernel32.lookupFunction<_GlobalLockNative, _GlobalLock>('GlobalLock'); + final globalUnlock = + kernel32.lookupFunction<_GlobalUnlockNative, _GlobalUnlock>('GlobalUnlock'); + + // Register custom "PNG" format (Chrome, Edge, Firefox, Snipping Tool export) + final pngNamePtr = 'PNG'.toNativeUtf16(); + final pngFormat = registerFormat(pngNamePtr); + malloc.free(pngNamePtr); + + if (openClipboard(0) == 0) return null; + + try { + // 1. Try custom PNG format — bytes are ready-to-use PNG + if (pngFormat != 0 && isFormatAvailable(pngFormat) != 0) { + final bytes = _readGlobalBytes( + getClipboardData(pngFormat), globalSize, globalLock, globalUnlock); + if (bytes != null && bytes.isNotEmpty) return bytes; + } + + // 2. Try CF_DIBV4 then CF_DIB — convert DIB to PNG via the image package + for (final fmt in [_CF_DIBV4, _CF_DIB]) { + if (isFormatAvailable(fmt) != 0) { + final dibBytes = _readGlobalBytes( + getClipboardData(fmt), globalSize, globalLock, globalUnlock); + if (dibBytes != null && dibBytes.isNotEmpty) { + final png = _dibToPng(dibBytes); + if (png != null) return png; + } + } + } + + return null; + } finally { + closeClipboard(); + } +} + +/// Copy the bytes pointed to by a global memory handle. +Uint8List? _readGlobalBytes( + int handle, + _GlobalSize globalSize, + _GlobalLock globalLock, + _GlobalUnlock globalUnlock, +) { + if (handle == 0) return null; + final size = globalSize(handle); + if (size == 0) return null; + final ptr = globalLock(handle); + if (ptr.address == 0) return null; + try { + return Uint8List.fromList(ptr.asTypedList(size)); + } finally { + globalUnlock(handle); + } +} + +/// Convert a CF_DIB / CF_DIBV4 block to PNG bytes using the `image` package. +/// +/// A DIB is essentially a BMP without the 14-byte file header. We prepend the +/// header and let the `image` package decode it. +Uint8List? _dibToPng(Uint8List dib) { + if (dib.length < 40) return null; // minimum BITMAPINFOHEADER size + + final bd = ByteData.sublistView(dib); + + final headerSize = bd.getUint32(0, Endian.little); // biSize + final bitCount = bd.getUint16(14, Endian.little); // biBitCount + final compression = bd.getUint32(16, Endian.little); // biCompression + final clrUsed = bd.getUint32(32, Endian.little); // biClrUsed + + // Calculate color table size (in bytes, each entry = 4 bytes RGBQUAD) + int colorTableEntries; + if (bitCount <= 8) { + colorTableEntries = clrUsed != 0 ? clrUsed : (1 << bitCount); + } else if (compression == 3 /* BI_BITFIELDS */ || compression == 6 /* BI_ALPHABITFIELDS */) { + colorTableEntries = 0; + } else { + colorTableEntries = clrUsed; + } + final colorTableBytes = colorTableEntries * 4; + + // Offset to pixel data from start of FILE (= 14 file header bytes + DIB) + final pixelOffset = 14 + headerSize + colorTableBytes; + + // Build the 14-byte BITMAPFILEHEADER + final fileSize = 14 + dib.length; + final fileHeader = Uint8List(14); + final fh = ByteData.sublistView(fileHeader); + fileHeader[0] = 0x42; // 'B' + fileHeader[1] = 0x4D; // 'M' + fh.setUint32(2, fileSize, Endian.little); + fh.setUint32(6, 0, Endian.little); + fh.setUint32(10, pixelOffset, Endian.little); + + final bmp = Uint8List(fileHeader.length + dib.length); + bmp.setRange(0, fileHeader.length, fileHeader); + bmp.setRange(fileHeader.length, bmp.length, dib); + + try { + final decoded = img.decodeBmp(bmp); + if (decoded == null) return null; + return Uint8List.fromList(img.encodePng(decoded)); + } catch (_) { + return null; + } +} diff --git a/lib/utils/gif_favorite_service.dart b/lib/utils/gif_favorite_service.dart new file mode 100644 index 0000000..c7f0294 --- /dev/null +++ b/lib/utils/gif_favorite_service.dart @@ -0,0 +1,444 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:http/http.dart' as http; + +const _stickerServerUrl = 'https://stickers.steggi-matrix.work'; + +class GifFavoriteService { + // ── GIF Favorites cache ───────────────────────────────────────────────── + static final List> _cache = []; + static bool _loaded = false; + + // ── Sticker Favorites cache ────────────────────────────────────────────── + static final List> _stickerCache = []; + static bool _stickerLoaded = false; + + static Future>> load() async { + if (_loaded) return _cache; + try { + final res = await http.get(Uri.parse('$_stickerServerUrl/gif-favorites')); + final data = jsonDecode(res.body) as List; + _cache + ..clear() + ..addAll(data.cast>()); + _loaded = true; + } catch (_) {} + return _cache; + } + + static List> get cache => List.unmodifiable(_cache); + + /// True if the given https:// URL is already a favorite. + static bool isFavorite(String url) => + _cache.any((f) => f['url'] == url); + + /// True if a favorite was created from this mxc:// source URL. + static bool isFavoriteByMxc(String mxcUrl) => + _cache.any((f) => f['mxc_source'] == mxcUrl); + + /// Upload raw GIF/WebP bytes to the Pi server. + /// Returns the public https:// URL on success, null on failure. + static Future uploadToPi(Uint8List bytes, String mimeType) async { + try { + final res = await http.post( + Uri.parse('$_stickerServerUrl/gif-file-upload'), + headers: {'Content-Type': mimeType}, + body: bytes, + ); + if (res.statusCode == 200) { + final data = jsonDecode(res.body); + return data['url'] as String?; + } + } catch (_) {} + return null; + } + + /// Toggle a plain external GIF (Giphy etc.). + static Future toggle( + String url, + String previewUrl, + String title, + ) async { + final removing = isFavorite(url); + if (removing) { + _cache.removeWhere((f) => f['url'] == url); + } else { + _cache.add({'url': url, 'preview_url': previewUrl, 'title': title}); + } + try { + await http.post( + Uri.parse('$_stickerServerUrl/gif-favorites'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'action': removing ? 'remove' : 'add', + 'url': url, + 'preview_url': previewUrl, + 'title': title, + }), + ); + } catch (_) { + // Revert on error + if (removing) { + _cache.add({'url': url, 'preview_url': previewUrl, 'title': title}); + } else { + _cache.removeWhere((f) => f['url'] == url); + } + } + } + + /// Add a favorite that was uploaded from an mxc:// source. + /// [url] is the Pi-hosted https:// URL; [mxcSource] is the original mxc:// URL. + static Future addWithMxcSource( + String url, + String previewUrl, + String title, + String mxcSource, + ) async { + final entry = { + 'url': url, + 'preview_url': previewUrl, + 'title': title, + 'mxc_source': mxcSource, + }; + _cache.add(entry); + try { + await http.post( + Uri.parse('$_stickerServerUrl/gif-favorites'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'action': 'add', + 'url': url, + 'preview_url': previewUrl, + 'title': title, + 'mxc_source': mxcSource, + }), + ); + } catch (_) { + _cache.removeWhere((f) => f['mxc_source'] == mxcSource); + } + } + + /// Remove a favorite that was originally added from an mxc:// source. + static Future removeByMxc(String mxcSource) async { + _cache.removeWhere((f) => f['mxc_source'] == mxcSource); + try { + await http.post( + Uri.parse('$_stickerServerUrl/gif-favorites'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'action': 'remove', + 'url': '', + 'mxc_source': mxcSource, + }), + ); + } catch (_) {} + } + + /// Extracts the URL logic from a matrix event to easily toggle favoriting globally. + static Future toggleEventFavorites(dynamic event) async { + await load(); + String rawUrl = ''; + final url = event.content['url'] as String?; + if (url != null && url.isNotEmpty) { + rawUrl = url; + } else { + final fileObj = event.content['file']; + if (fileObj is Map) { + final fileUrl = fileObj['url']; + if (fileUrl is String && fileUrl.isNotEmpty) rawUrl = fileUrl; + } + } + + if (rawUrl.isEmpty) return false; + + final u = Uri.tryParse(rawUrl); + final isExternal = u != null && (u.scheme == 'https' || u.scheme == 'http'); + final title = event.content['body'] as String? ?? 'Sticker'; + final isFav = isExternal ? isFavorite(rawUrl) : isFavoriteByMxc(rawUrl); + + if (isExternal) { + await toggle(rawUrl, rawUrl, title); + return true; + } else if (rawUrl.startsWith('mxc://')) { + if (isFav) { + await removeByMxc(rawUrl); + return true; + } else { + try { + final matrixFile = await event.downloadAndDecryptAttachment(getThumbnail: false); + final mimeType = event.infoMap['mimetype'] as String? ?? 'image/webp'; + final piUrl = await uploadToPi(matrixFile.bytes, mimeType); + if (piUrl != null) { + await addWithMxcSource(piUrl, piUrl, title, rawUrl); + return true; + } + } catch (e) { + return false; + } + } + } + return false; + } + + // ── Sticker Favorites ──────────────────────────────────────────────────── + + /// Load sticker favorites from the server into the sticker cache. + static Future>> loadStickers() async { + if (_stickerLoaded) return _stickerCache; + try { + final res = await http.get(Uri.parse('$_stickerServerUrl/sticker-favorites')); + final data = jsonDecode(res.body) as List; + _stickerCache + ..clear() + ..addAll(data.cast>()); + _stickerLoaded = true; + } catch (_) {} + return _stickerCache; + } + + static List> get stickerCache => List.unmodifiable(_stickerCache); + + /// All user-created stickers (saved from images). Shown in "Meine Sticker". + static List> get userStickerItems => + List.unmodifiable( + _stickerCache.where((s) => s['user_sticker'] == true).toList(), + ); + + /// Explicitly favorited stickers. Includes: + /// - User stickers with favorited == true + /// - Pack sticker favorites (entries without user_sticker == true) + static List> get stickerFavoritesItems => + List.unmodifiable( + _stickerCache.where((s) { + if (s['user_sticker'] == true) return s['favorited'] == true; + return true; + }).toList(), + ); + + /// Remove a sticker from the local cache immediately (without a server round-trip). + /// Call this after successfully posting a remove action to the server. + static void removeSticker(String mxcUrl) { + _stickerCache.removeWhere((s) => s['mxc_source'] == mxcUrl); + } + + /// True if this mxc:// URL is in the effective favorites list. + static bool isStickerFavorite(String mxcUrl) => + stickerFavoritesItems.any((s) => s['mxc_source'] == mxcUrl); + + /// Toggle the favorited flag on a user sticker. Returns true on success. + static Future setUserStickerFavorited(String mxcSource, bool favorited) async { + final idx = _stickerCache.indexWhere((s) => s['mxc_source'] == mxcSource); + if (idx == -1) return false; + _stickerCache[idx]['favorited'] = favorited; + try { + await http.post( + Uri.parse('$_stickerServerUrl/sticker-favorites'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'action': 'set_favorited', + 'mxc_source': mxcSource, + 'favorited': favorited, + }), + ); + return true; + } catch (_) { + _stickerCache[idx]['favorited'] = !favorited; + return false; + } + } + + /// Add or remove any image/sticker event from sticker favorites. + /// Handles both unencrypted (content['url']) and encrypted (content['file']['url']) events. + /// For encrypted events, re-uploads the decrypted bytes to Matrix so the + /// stored mxc:// URL can be sent as a sticker without decryption keys. + static Future addImageAsStickerFavorite(dynamic event) async { + await loadStickers(); + + // Resolve original mxc URL (used as deduplication key) + String rawMxc = event.content['url'] as String? ?? ''; + final bool isEncrypted = event.content['file'] != null; + if (rawMxc.isEmpty && isEncrypted) { + final fileObj = event.content['file']; + if (fileObj is Map) rawMxc = fileObj['url'] as String? ?? ''; + } + if (rawMxc.isEmpty) return false; + + final title = event.content['body'] as String? ?? 'Sticker'; + + // Check if ALREADY in cache (by mxc_source), regardless of favorited status. + // This prevents duplicates when user taps "Als Sticker speichern" on an already-saved sticker. + final existingIdx = _stickerCache.indexWhere((s) => s['mxc_source'] == rawMxc); + if (existingIdx != -1) { + // Toggle: remove the existing user sticker + final backup = Map.from(_stickerCache[existingIdx]); + _stickerCache.removeAt(existingIdx); + try { + await http.post( + Uri.parse('$_stickerServerUrl/sticker-favorites'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'action': 'remove', 'mxc_source': rawMxc}), + ); + return true; + } catch (_) { + _stickerCache.insert(existingIdx, backup); + return false; + } + } else { + try { + final matrixFile = await event.downloadAndDecryptAttachment(getThumbnail: false); + final mimeType = (event.infoMap['mimetype'] as String?) ?? 'image/png'; + + // For encrypted events the original mxc:// points to ciphertext. + // Re-upload the decrypted bytes to get a plain, sendable mxc:// URL. + String stickerMxc; + if (isEncrypted) { + final dynamic uploadedUri = await event.room.client.uploadContent( + matrixFile.bytes, + filename: 'sticker', + contentType: mimeType, + ); + stickerMxc = uploadedUri.toString(); + } else { + stickerMxc = rawMxc; + } + + // Also upload to Pi server for the picker preview thumbnail + final piUrl = await uploadToPi(matrixFile.bytes, mimeType); + if (piUrl == null) return false; + + final entry = { + 'mxc_source': rawMxc, // deduplication key (original encrypted or plain mxc) + 'mxc': stickerMxc, // sendable unencrypted mxc:// URL + 'url': piUrl, // Pi preview URL shown in the picker + 'title': title, + 'mimetype': mimeType, + 'user_sticker': true, // created from an image, shown in "Meine Sticker" + 'favorited': false, // not in favorites until explicitly hearted + }; + _stickerCache.add(entry); + await http.post( + Uri.parse('$_stickerServerUrl/sticker-favorites'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'action': 'add', ...entry}), + ); + return true; + } catch (_) { + _stickerCache.removeWhere((s) => s['mxc_source'] == rawMxc); + return false; + } + } + } + + /// Add or remove a pack sticker from favorites using its already-accessible + /// thumbnail URL — no download or re-upload needed. + static Future togglePackStickerFavorite({ + required String mxcUrl, + required String thumbUrl, + required String title, + String mimetype = 'image/webp', + }) async { + await loadStickers(); + + final isFav = isStickerFavorite(mxcUrl); + + if (isFav) { + _stickerCache.removeWhere((s) => s['mxc_source'] == mxcUrl); + try { + await http.post( + Uri.parse('$_stickerServerUrl/sticker-favorites'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'action': 'remove', 'mxc_source': mxcUrl}), + ); + return true; + } catch (_) { + _stickerCache.add({'mxc_source': mxcUrl, 'mxc': mxcUrl, 'url': thumbUrl, 'title': title}); + return false; + } + } else { + final entry = { + 'mxc_source': mxcUrl, + 'mxc': mxcUrl, + 'url': thumbUrl, + 'title': title, + 'mimetype': mimetype, + 'user_sticker': false, + 'favorited': false, + }; + _stickerCache.add(entry); + try { + await http.post( + Uri.parse('$_stickerServerUrl/sticker-favorites'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'action': 'add', ...entry}), + ); + return true; + } catch (_) { + _stickerCache.removeWhere((s) => s['mxc_source'] == mxcUrl); + return false; + } + } + } + + /// Toggle a sticker event into/out of the sticker favorites. + /// For user-created stickers, only the favorited flag is toggled. + /// For pack stickers, the sticker is added/removed from the favorites list. + static Future toggleStickerFavorite(dynamic event) async { + await loadStickers(); + final rawMxc = event.content['url'] as String? ?? ''; + if (rawMxc.isEmpty) return false; + final title = event.content['body'] as String? ?? 'Sticker'; + + // If it's a user-created sticker, toggle its favorited flag only + final userIdx = _stickerCache.indexWhere( + (s) => s['mxc_source'] == rawMxc && s['user_sticker'] == true, + ); + if (userIdx != -1) { + final currentFav = _stickerCache[userIdx]['favorited'] == true; + return setUserStickerFavorited(rawMxc, !currentFav); + } + + final isFav = isStickerFavorite(rawMxc); + + if (isFav) { + _stickerCache.removeWhere((s) => s['mxc_source'] == rawMxc); + try { + await http.post( + Uri.parse('$_stickerServerUrl/sticker-favorites'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'action': 'remove', 'mxc_source': rawMxc}), + ); + return true; + } catch (_) { + _stickerCache.add({'mxc_source': rawMxc, 'title': title}); + return false; + } + } else { + // Upload thumbnail bytes to Pi for preview + try { + final matrixFile = await event.downloadAndDecryptAttachment(getThumbnail: false); + final mimeType = event.infoMap['mimetype'] as String? ?? 'image/webp'; + final piUrl = await uploadToPi(matrixFile.bytes, mimeType); + if (piUrl == null) return false; + final entry = { + 'mxc_source': rawMxc, + 'mxc': rawMxc, + 'url': piUrl, + 'title': title, + 'mimetype': mimeType, + 'user_sticker': false, + 'favorited': false, + }; + _stickerCache.add(entry); + await http.post( + Uri.parse('$_stickerServerUrl/sticker-favorites'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'action': 'add', ...entry}), + ); + return true; + } catch (_) { + _stickerCache.removeWhere((s) => s['mxc_source'] == rawMxc); + return false; + } + } + } +} diff --git a/lib/widgets/banner_crop_dialog.dart b/lib/widgets/banner_crop_dialog.dart new file mode 100644 index 0000000..f76a744 --- /dev/null +++ b/lib/widgets/banner_crop_dialog.dart @@ -0,0 +1,184 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:pyramid/core/theme.dart'; + +class BannerCropDialog extends StatefulWidget { + final Uint8List imageBytes; + // Width-to-height ratio for the crop frame (e.g. 2.4 = 2.4× wider than tall) + final double aspectRatio; + + const BannerCropDialog({ + super.key, + required this.imageBytes, + this.aspectRatio = 2.4, + }); + + @override + State createState() => _BannerCropDialogState(); +} + +class _BannerCropDialogState extends State { + final _transformController = TransformationController(); + final _repaintKey = GlobalKey(); + bool _exporting = false; + + @override + void dispose() { + _transformController.dispose(); + super.dispose(); + } + + Future _confirm() async { + setState(() => _exporting = true); + try { + final boundary = _repaintKey.currentContext!.findRenderObject() + as RenderRepaintBoundary; + // pixelRatio 2 gives enough resolution for a banner + final image = await boundary.toImage(pixelRatio: 2.0); + final byteData = + await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null || !mounted) return; + Navigator.of(context).pop(byteData.buffer.asUint8List()); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Fehler beim Zuschneiden: $e'))); + setState(() => _exporting = false); + } + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + + return Dialog( + backgroundColor: pt.bg1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rLg)), + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── Header ──────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 12, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Banner zuschneiden', + style: TextStyle( + color: pt.fg, + fontSize: 16, + fontWeight: FontWeight.w700)), + Text( + 'Ziehe und zoome das Bild zum gewünschten Ausschnitt', + style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + IconButton( + icon: Icon(Icons.close, color: pt.fgDim, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + // ── Crop frame ──────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: AspectRatio( + aspectRatio: widget.aspectRatio, + child: ClipRRect( + borderRadius: BorderRadius.circular(pt.rBase), + child: RepaintBoundary( + key: _repaintKey, + child: Container( + color: Colors.black, + child: InteractiveViewer( + transformationController: _transformController, + minScale: 0.25, + maxScale: 8.0, + child: Image.memory( + widget.imageBytes, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + ), + ), + ), + ), + ), + // ── Hint ────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 0), + child: Row( + children: [ + Icon(Icons.info_outline, size: 13, color: pt.fgDim), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Pinch/Scroll zum Zoomen, Drag zum Positionieren', + style: TextStyle(color: pt.fgDim, fontSize: 11), + ), + ), + TextButton( + onPressed: () { + _transformController.value = Matrix4.identity(); + }, + child: + Text('Zurücksetzen', style: TextStyle(color: pt.fgDim, fontSize: 11)), + ), + ], + ), + ), + // ── Actions ─────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _exporting + ? null + : () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _exporting ? null : _confirm, + style: FilledButton.styleFrom( + backgroundColor: pt.accent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rBase)), + ), + child: _exporting + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: pt.bg0)) + : const Text('Übernehmen', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600)), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/create_join_dialog.dart b/lib/widgets/create_join_dialog.dart new file mode 100644 index 0000000..87c88fa --- /dev/null +++ b/lib/widgets/create_join_dialog.dart @@ -0,0 +1,1047 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' hide Visibility; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; + +enum CreateJoinTab { create, join, discover } +enum _CreateType { room, voice, space } + +class CreateJoinDialog extends ConsumerStatefulWidget { + final CreateJoinTab initialTab; + const CreateJoinDialog({super.key, this.initialTab = CreateJoinTab.create}); + + @override + ConsumerState createState() => _CreateJoinDialogState(); +} + +class _CreateJoinDialogState extends ConsumerState { + late CreateJoinTab _tab; + _CreateType _createType = _CreateType.room; + + // Create + final _nameCtrl = TextEditingController(); + final _topicCtrl = TextEditingController(); + final _aliasCtrl = TextEditingController(); + bool _isPublic = false; + bool _isEncrypted = true; + bool _creating = false; + String? _createError; + + // Join + final _joinCtrl = TextEditingController(); + bool _joining = false; + String? _joinError; + + // Discover + final _discoverServerCtrl = TextEditingController(); + final _discoverSearchCtrl = TextEditingController(); + List _publicRooms = []; + String? _nextBatch; + bool _discoverLoading = false; + bool _discoverLoadingMore = false; + String? _discoverError; + Timer? _searchDebounce; + final Set _joiningRooms = {}; + + @override + void initState() { + super.initState(); + _tab = widget.initialTab; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_tab == CreateJoinTab.discover) _discover(); + }); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _topicCtrl.dispose(); + _aliasCtrl.dispose(); + _joinCtrl.dispose(); + _discoverServerCtrl.dispose(); + _discoverSearchCtrl.dispose(); + _searchDebounce?.cancel(); + super.dispose(); + } + + Future _create() async { + final name = _nameCtrl.text.trim(); + if (name.isEmpty) { + setState(() => _createError = 'Bitte einen Namen eingeben.'); + return; + } + setState(() { _creating = true; _createError = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + String roomId; + if (_createType == _CreateType.space) { + roomId = await client.createSpace( + name: name, + topic: _topicCtrl.text.trim().isEmpty ? null : _topicCtrl.text.trim(), + visibility: _isPublic ? Visibility.public : Visibility.private, + spaceAliasName: _aliasCtrl.text.trim().isEmpty ? null : _aliasCtrl.text.trim(), + ); + } else if (_createType == _CreateType.voice) { + roomId = await client.createRoom( + name: name, + topic: _topicCtrl.text.trim().isEmpty ? null : _topicCtrl.text.trim(), + visibility: Visibility.private, + preset: CreateRoomPreset.privateChat, + creationContent: {'type': 'io.pyramid.voice_channel'}, + ); + // Voice-Presence (io.pyramid.voice.presence) muss von JEDEM Mitglied + // schreibbar sein (state_default ist 50, normale Mitglieder haben 0) — + // sonst sieht man nur den Raum-Ersteller im Kanal. Nachträglich setzen, + // weil Continuwuity power_level_content_override beim createRoom + // ignoriert. + try { + final plRaw = await client.getRoomStateWithKey( + roomId, EventTypes.RoomPowerLevels, ''); + final pl = Map.from(plRaw); + final events = + Map.from(pl['events'] as Map? ?? {}); + events['io.pyramid.voice.presence'] = 0; + pl['events'] = events; + await client.setRoomStateWithKey( + roomId, EventTypes.RoomPowerLevels, '', pl); + } catch (_) { + // Nicht kritisch — Presence-Anzeige funktioniert dann nur für Admins. + } + } else { + roomId = await client.createRoom( + name: name, + topic: _topicCtrl.text.trim().isEmpty ? null : _topicCtrl.text.trim(), + visibility: _isPublic ? Visibility.public : Visibility.private, + roomAliasName: _aliasCtrl.text.trim().isEmpty ? null : _aliasCtrl.text.trim(), + preset: _isPublic ? CreateRoomPreset.publicChat : CreateRoomPreset.privateChat, + initialState: _isEncrypted && !_isPublic + ? [StateEvent(content: {'algorithm': 'm.megolm.v1.aes-sha2'}, type: EventTypes.Encryption)] + : null, + ); + } + if (mounted) { + ref.read(activeRoomIdProvider.notifier).state = roomId; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + Navigator.of(context).pop(); + } + } on MatrixException catch (e) { + if (mounted) setState(() { _creating = false; _createError = e.errorMessage; }); + } catch (e) { + if (mounted) setState(() { _creating = false; _createError = e.toString().split('\n').first; }); + } + } + + Future _join() async { + final addr = _joinCtrl.text.trim(); + if (addr.isEmpty) { + setState(() => _joinError = 'Bitte eine Raum-Adresse eingeben.'); + return; + } + setState(() { _joining = true; _joinError = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + final roomId = await client.joinRoom(addr); + if (mounted) { + ref.read(activeRoomIdProvider.notifier).state = roomId; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + Navigator.of(context).pop(); + } + } on MatrixException catch (e) { + if (mounted) setState(() { _joining = false; _joinError = e.errorMessage; }); + } catch (e) { + if (mounted) setState(() { _joining = false; _joinError = e.toString().split('\n').first; }); + } + } + + Future _discover({bool loadMore = false}) async { + if (loadMore) { + if (_nextBatch == null) return; + setState(() => _discoverLoadingMore = true); + } else { + setState(() { _discoverLoading = true; _discoverError = null; }); + } + try { + final client = await ref.read(matrixClientProvider.future); + final server = _discoverServerCtrl.text.trim().isEmpty ? null : _discoverServerCtrl.text.trim(); + final search = _discoverSearchCtrl.text.trim(); + final res = await client.queryPublicRooms( + server: server, + limit: 30, + since: loadMore ? _nextBatch : null, + filter: search.isEmpty ? null : PublicRoomQueryFilter(genericSearchTerm: search), + ); + if (mounted) { + setState(() { + if (loadMore) { + _publicRooms.addAll(res.chunk); + } else { + _publicRooms = res.chunk; + } + _nextBatch = res.nextBatch; + _discoverLoading = false; + _discoverLoadingMore = false; + }); + } + } catch (e) { + if (mounted) setState(() { + _discoverLoading = false; + _discoverLoadingMore = false; + _discoverError = e.toString().split('\n').first; + }); + } + } + + void _onSearchChanged(String _) { + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 500), _discover); + } + + Future _joinPublicRoom(String roomId, List? via) async { + setState(() => _joiningRooms.add(roomId)); + try { + final client = await ref.read(matrixClientProvider.future); + final id = await client.joinRoom(roomId, via: via); + if (mounted) { + ref.read(activeRoomIdProvider.notifier).state = id; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + Navigator.of(context).pop(); + } + } catch (_) { + if (mounted) setState(() => _joiningRooms.remove(roomId)); + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final size = MediaQuery.sizeOf(context); + final isNarrow = size.width < 600; + + return Dialog( + backgroundColor: pt.bg1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isNarrow ? size.width * 0.97 : 580, + maxHeight: size.height * 0.85, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildTabs(pt), + Divider(color: pt.border, height: 1), + Flexible(child: _buildBody(pt)), + ], + ), + ), + ); + } + + Widget _buildTabs(PyramidTheme pt) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 12, 12), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + _Tab(label: 'Erstellen', icon: Icons.add_rounded, active: _tab == CreateJoinTab.create, + pt: pt, onTap: () => setState(() => _tab = CreateJoinTab.create)), + const SizedBox(width: 4), + _Tab(label: 'Beitreten', icon: Icons.login_rounded, active: _tab == CreateJoinTab.join, + pt: pt, onTap: () => setState(() => _tab = CreateJoinTab.join)), + const SizedBox(width: 4), + _Tab(label: 'Entdecken', icon: Icons.explore_rounded, active: _tab == CreateJoinTab.discover, + pt: pt, onTap: () { + setState(() => _tab = CreateJoinTab.discover); + if (_publicRooms.isEmpty) _discover(); + }), + ], + ), + ), + IconButton( + icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + ), + ], + ), + ); + } + + Widget _buildBody(PyramidTheme pt) { + return switch (_tab) { + CreateJoinTab.create => _buildCreate(pt), + CreateJoinTab.join => _buildJoin(pt), + CreateJoinTab.discover => _buildDiscover(pt), + }; + } + + Widget _buildCreate(PyramidTheme pt) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Type toggle + Row(children: [ + _TypeToggle( + label: 'Raum', + icon: Icons.tag_rounded, + active: _createType == _CreateType.room, + pt: pt, + onTap: () => setState(() { _createType = _CreateType.room; _isPublic = false; }), + ), + const SizedBox(width: 8), + _TypeToggle( + label: 'Voice', + icon: Icons.mic_rounded, + active: _createType == _CreateType.voice, + pt: pt, + onTap: () => setState(() { _createType = _CreateType.voice; _isPublic = false; }), + ), + const SizedBox(width: 8), + _TypeToggle( + label: 'Space', + icon: Icons.workspaces_rounded, + active: _createType == _CreateType.space, + pt: pt, + onTap: () => setState(() => _createType = _CreateType.space), + ), + ]), + const SizedBox(height: 16), + _Field(ctrl: _nameCtrl, label: 'Name *', pt: pt, autofocus: true), + const SizedBox(height: 10), + _Field(ctrl: _topicCtrl, label: 'Thema / Beschreibung (optional)', pt: pt, maxLines: 2), + const SizedBox(height: 14), + // Options + if (_createType == _CreateType.voice) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon(Icons.info_outline, size: 14, color: pt.fgDim), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Voice-Kanäle sind privat. Mitglieder können beitreten und direkt sprechen.', + style: TextStyle(color: pt.fgDim, fontSize: 12), + ), + ), + ], + ), + ), + if (_createType != _CreateType.voice) ...[ + _OptionRow( + label: 'Öffentlich', + desc: 'Im Verzeichnis sichtbar, jeder kann beitreten', + pt: pt, + value: _isPublic, + onChanged: (v) => setState(() => _isPublic = v), + ), + if (_createType == _CreateType.room) ...[ + const SizedBox(height: 8), + _OptionRow( + label: 'Verschlüsselt', + desc: 'Ende-zu-Ende-Verschlüsselung aktivieren', + pt: pt, + value: _isEncrypted, + onChanged: (v) => setState(() => _isEncrypted = v), + ), + ], + if (_isPublic) ...[ + const SizedBox(height: 10), + _Field( + ctrl: _aliasCtrl, + label: 'Alias (optional)', + pt: pt, + hint: 'mein-raum → #mein-raum:server', + ), + ], + ], + if (_createError != null) ...[ + const SizedBox(height: 10), + _ErrorCard(text: _createError!, pt: pt), + ], + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _creating ? null : _create, + child: _creating + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) + : Text(_createType == _CreateType.space ? 'Space erstellen' : 'Raum erstellen'), + ), + ), + ], + ), + ); + } + + Widget _buildJoin(PyramidTheme pt) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Raum-Adresse oder ID eingeben', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5)), + const SizedBox(height: 12), + _Field( + ctrl: _joinCtrl, + label: 'Adresse oder ID', + hint: '#kanal:server.de oder !abc123:server.de', + pt: pt, + autofocus: true, + onSubmitted: (_) => _join(), + ), + const SizedBox(height: 6), + Text('Tipp: Matrix-Räume, Spaces und Gruppen werden alle unterstützt.', + style: TextStyle(color: pt.fgDim, fontSize: 11)), + if (_joinError != null) ...[ + const SizedBox(height: 10), + _ErrorCard(text: _joinError!, pt: pt), + ], + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _joining ? null : _join, + child: _joining + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) + : const Text('Beitreten'), + ), + ), + ], + ), + ); + } + + Widget _buildDiscover(PyramidTheme pt) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row(children: [ + Expanded( + child: _Field( + ctrl: _discoverSearchCtrl, + label: 'Suchen', + pt: pt, + onChanged: _onSearchChanged, + prefixIcon: Icon(Icons.search_rounded, size: 16, color: pt.fgDim), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 140, + child: _Field( + ctrl: _discoverServerCtrl, + label: 'Server', + hint: 'matrix.org', + pt: pt, + onSubmitted: (_) => _discover(), + ), + ), + ]), + ), + Divider(color: pt.border, height: 1), + Flexible( + child: _discoverLoading + ? const Center(child: CircularProgressIndicator.adaptive()) + : _discoverError != null + ? Center(child: _ErrorCard(text: _discoverError!, pt: pt)) + : _publicRooms.isEmpty + ? Center(child: Text('Keine Räume gefunden.', style: TextStyle(color: pt.fgMuted, fontSize: 13))) + : ListView.builder( + itemCount: _publicRooms.length + (_nextBatch != null ? 1 : 0), + itemBuilder: (ctx, i) { + if (i == _publicRooms.length) { + return Padding( + padding: const EdgeInsets.all(12), + child: Center( + child: _discoverLoadingMore + ? const CircularProgressIndicator.adaptive() + : TextButton( + onPressed: () => _discover(loadMore: true), + child: Text('Mehr laden', style: TextStyle(color: pt.accent)), + ), + ), + ); + } + final room = _publicRooms[i]; + return _PublicRoomItem( + room: room, + pt: pt, + isJoining: _joiningRooms.contains(room.roomId), + onJoin: () => _joinPublicRoom(room.roomId, null), + ); + }, + ), + ), + ], + ); + } +} + +// ── Space / Room editor dialog ───────────────────────────────────────────────── + +class SpaceEditDialog extends ConsumerStatefulWidget { + final Room space; + const SpaceEditDialog({super.key, required this.space}); + + @override + ConsumerState createState() => _SpaceEditDialogState(); +} + +class _SpaceEditDialogState extends ConsumerState { + late TextEditingController _nameCtrl; + late TextEditingController _topicCtrl; + bool _saving = false; + String? _error; + bool _saved = false; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController(text: widget.space.getLocalizedDisplayname()); + _topicCtrl = TextEditingController(text: widget.space.topic ?? ''); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _topicCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { _saving = true; _error = null; _saved = false; }); + try { + final name = _nameCtrl.text.trim(); + final topic = _topicCtrl.text.trim(); + if (name.isNotEmpty && name != widget.space.getLocalizedDisplayname()) { + await widget.space.setName(name); + } + if (topic != (widget.space.topic ?? '')) { + await widget.space.setDescription(topic); + } + if (mounted) setState(() { _saving = false; _saved = true; }); + await Future.delayed(const Duration(seconds: 1)); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; }); + } + } + + Future _leave() async { + final pt = PyramidTheme.of(context); + 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('Space verlassen?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text('Du verlässt "${widget.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'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + await widget.space.leave(); + if (mounted) { + ref.read(activeSpaceIdProvider.notifier).state = 'rooms'; + Navigator.of(context).pop(true); + } + } catch (e) { + if (mounted) setState(() => _error = e.toString().split('\n').first); + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return Dialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.workspaces_rounded, size: 18, color: pt.accent), + const SizedBox(width: 8), + Expanded(child: Text('Space bearbeiten', + style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600))), + IconButton( + icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + ), + ]), + const SizedBox(height: 16), + _Field(ctrl: _nameCtrl, label: 'Name', pt: pt), + const SizedBox(height: 10), + _Field(ctrl: _topicCtrl, label: 'Beschreibung', pt: pt, maxLines: 2), + if (_error != null) ...[ + const SizedBox(height: 10), + _ErrorCard(text: _error!, pt: pt), + ], + const SizedBox(height: 16), + Row(children: [ + Expanded(child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: pt.danger, side: BorderSide(color: pt.danger.withAlpha(80)), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: _leave, + child: const Text('Verlassen'), + )), + const SizedBox(width: 10), + Expanded(child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) + : const Text('Speichern'), + )), + ]), + ], + ), + ), + ), + ); + } +} + +// ── Room settings dialog ─────────────────────────────────────────────────────── + +class RoomSettingsDialog extends ConsumerStatefulWidget { + final Room room; + const RoomSettingsDialog({super.key, required this.room}); + + @override + ConsumerState createState() => _RoomSettingsDialogState(); +} + +class _RoomSettingsDialogState extends ConsumerState { + late TextEditingController _nameCtrl; + late TextEditingController _topicCtrl; + bool _saving = false; + String? _error; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController(text: widget.room.getLocalizedDisplayname()); + _topicCtrl = TextEditingController(text: widget.room.topic ?? ''); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _topicCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { _saving = true; _error = null; }); + try { + if (_nameCtrl.text.trim() != widget.room.getLocalizedDisplayname()) { + await widget.room.setName(_nameCtrl.text.trim()); + } + final newTopic = _topicCtrl.text.trim(); + if (newTopic != (widget.room.topic ?? '')) { + await widget.room.setDescription(newTopic); + } + if (mounted) Navigator.of(context).pop(); + } catch (e) { + if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; }); + } + } + + Future _leaveRoom() async { + final pt = PyramidTheme.of(context); + 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 roomId = widget.room.id; + await widget.room.leave(); + if (mounted) { + final current = ref.read(activeRoomIdProvider); + if (current == roomId) ref.read(activeRoomIdProvider.notifier).state = null; + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) setState(() => _error = e.toString().split('\n').first); + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final canEdit = widget.room.canSendEvent(EventTypes.RoomName); + + return Dialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.settings_rounded, size: 18, color: pt.accent), + const SizedBox(width: 8), + Expanded(child: Text('Raum-Einstellungen', + style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600))), + IconButton( + icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + ), + ]), + const SizedBox(height: 6), + SelectableText(widget.room.id, + style: TextStyle(color: pt.fgDim, fontSize: 11, fontFamily: 'monospace')), + const SizedBox(height: 16), + if (canEdit) ...[ + _Field(ctrl: _nameCtrl, label: 'Name', pt: pt), + const SizedBox(height: 10), + _Field(ctrl: _topicCtrl, label: 'Thema', pt: pt, maxLines: 2), + ] else + Text('Du hast keine Berechtigung, diesen Raum zu bearbeiten.', + style: TextStyle(color: pt.fgMuted, fontSize: 13)), + if (_error != null) ...[ + const SizedBox(height: 10), + _ErrorCard(text: _error!, pt: pt), + ], + const SizedBox(height: 16), + Row(children: [ + Expanded(child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: pt.danger, side: BorderSide(color: pt.danger.withAlpha(80)), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: _leaveRoom, + child: const Text('Verlassen'), + )), + if (canEdit) ...[ + const SizedBox(width: 10), + Expanded(child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) + : const Text('Speichern'), + )), + ], + ]), + ], + ), + ), + ), + ); + } +} + +// ── Helper widgets ───────────────────────────────────────────────────────────── + +class _Tab extends StatefulWidget { + final String label; + final IconData icon; + final bool active; + final PyramidTheme pt; + final VoidCallback onTap; + const _Tab({required this.label, required this.icon, required this.active, required this.pt, required this.onTap}); + + @override + State<_Tab> createState() => _TabState(); +} + +class _TabState extends State<_Tab> { + 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: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: widget.active ? pt.accentSoft : (_hovered ? pt.bgHover : Colors.transparent), + borderRadius: BorderRadius.circular(6), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(widget.icon, size: 14, color: widget.active ? pt.accent : pt.fgMuted), + const SizedBox(width: 6), + Text(widget.label, style: TextStyle( + color: widget.active ? pt.fg : pt.fgMuted, + fontSize: 13, fontWeight: widget.active ? FontWeight.w600 : FontWeight.w400, + )), + ]), + ), + ), + ); + } +} + +class _TypeToggle extends StatelessWidget { + final String label; + final IconData icon; + final bool active; + final PyramidTheme pt; + final VoidCallback onTap; + const _TypeToggle({required this.label, required this.icon, required this.active, required this.pt, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: active ? pt.accent : pt.bg3, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: active ? pt.accent : pt.border), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 14, color: active ? pt.accentFg : pt.fgMuted), + const SizedBox(width: 6), + Text(label, style: TextStyle( + color: active ? pt.accentFg : pt.fgMuted, + fontSize: 13, fontWeight: FontWeight.w500, + )), + ]), + ), + ); + } +} + +class _OptionRow extends StatelessWidget { + final String label; + final String desc; + final bool value; + final ValueChanged onChanged; + final PyramidTheme pt; + const _OptionRow({required this.label, required this.desc, required this.value, required this.onChanged, required this.pt}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onChanged(!value), + child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), + Text(desc, style: TextStyle(color: pt.fgDim, fontSize: 11)), + ])), + Switch(value: value, onChanged: onChanged, activeColor: pt.accent), + ]), + ); + } +} + +class _Field extends StatelessWidget { + final TextEditingController ctrl; + final String label; + final String? hint; + final PyramidTheme pt; + final bool autofocus; + final int maxLines; + final Widget? prefixIcon; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + + const _Field({ + required this.ctrl, required this.label, required this.pt, + this.hint, this.autofocus = false, this.maxLines = 1, + this.prefixIcon, this.onChanged, this.onSubmitted, + }); + + @override + Widget build(BuildContext context) => TextField( + controller: ctrl, + autofocus: autofocus, + maxLines: maxLines, + style: TextStyle(color: pt.fg, fontSize: 13), + onChanged: onChanged, + onSubmitted: onSubmitted, + decoration: InputDecoration( + labelText: label, + hintText: hint, + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + hintStyle: TextStyle(color: pt.fgDim, fontSize: 12), + prefixIcon: prefixIcon, + filled: true, fillColor: pt.bg3, + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + 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, + ); +} + +class _ErrorCard extends StatelessWidget { + final String text; + final PyramidTheme pt; + const _ErrorCard({required this.text, required this.pt}); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: pt.danger.withAlpha(20), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: pt.danger.withAlpha(60)), + ), + child: Row(children: [ + Icon(Icons.error_outline_rounded, size: 14, color: pt.danger), + const SizedBox(width: 8), + Expanded(child: Text(text, style: TextStyle(color: pt.danger, fontSize: 12))), + ]), + ); +} + +class _PublicRoomItem extends ConsumerWidget { + final PublicRoomsChunk room; + final PyramidTheme pt; + final bool isJoining; + final VoidCallback onJoin; + const _PublicRoomItem({required this.room, required this.pt, required this.isJoining, required this.onJoin}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final client = ref.watch(matrixClientProvider).valueOrNull; + final name = room.name ?? room.canonicalAlias ?? room.roomId; + final avatarUrl = room.avatarUrl; + final alreadyJoined = client?.getRoomById(room.roomId) != null; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row(children: [ + // Avatar + Container( + width: 36, height: 36, + decoration: BoxDecoration(color: pt.bg3, borderRadius: BorderRadius.circular(8)), + child: (client != null && avatarUrl != null) + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: MxcImage(mxcUri: avatarUrl.toString(), client: client, fit: BoxFit.cover), + ) + : Center(child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w700), + )), + ), + const SizedBox(width: 10), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(name, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis), + if (room.topic != null) + Text(room.topic!, style: TextStyle(color: pt.fgDim, fontSize: 11), + overflow: TextOverflow.ellipsis, maxLines: 1), + Text('${room.numJoinedMembers} Mitglieder', + style: TextStyle(color: pt.fgDim, fontSize: 11)), + ])), + const SizedBox(width: 8), + if (alreadyJoined) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(6)), + child: Text('Beigetreten', style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600)), + ) + else if (isJoining) + const SizedBox(width: 20, height: 20, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) + else + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + onPressed: onJoin, + child: const Text('Beitreten', style: TextStyle(fontSize: 12)), + ), + ]), + ); + } +} diff --git a/lib/widgets/hover_region.dart b/lib/widgets/hover_region.dart new file mode 100644 index 0000000..23d0550 --- /dev/null +++ b/lib/widgets/hover_region.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +// Simple hover-aware widget for desktop hover effects +class HoverRegion extends StatefulWidget { + final Widget Function(BuildContext context, bool hovered) builder; + final MouseCursor cursor; + + const HoverRegion({ + super.key, + required this.builder, + this.cursor = SystemMouseCursors.click, + }); + + @override + State createState() => _HoverRegionState(); +} + +class _HoverRegionState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: widget.cursor, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: widget.builder(context, _hovered), + ); + } +} + +// Icon button styled to match Pyramid's .icon-btn +class PyrIconBtn extends StatefulWidget { + final Widget icon; + final VoidCallback? onPressed; + final String? tooltip; + final bool active; + final Color? activeColor; + final Color? dangerColor; + final double size; + + const PyrIconBtn({ + super.key, + required this.icon, + this.onPressed, + this.tooltip, + this.active = false, + this.activeColor, + this.dangerColor, + this.size = 32, + }); + + @override + State createState() => _PyrIconBtnState(); +} + +class _PyrIconBtnState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final fgMuted = isDark ? const Color(0xFFA4A4B0) : const Color(0xFF54545E); + final fg = isDark ? const Color(0xFFECECF0) : const Color(0xFF1A1A1F); + final bgHover = isDark ? const Color(0xFF232330) : const Color(0xFFE9E7E1); + + final color = widget.active + ? (widget.activeColor ?? const Color(0xFFF5A614)) + : _hovered ? fg : fgMuted; + + final platform = Theme.of(context).platform; + final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; + // On mobile, use a slightly larger touch target, but respect the requested size + final touchSize = isMobile ? (widget.size + 8).clamp(32.0, 48.0) : widget.size; + + final btn = MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onPressed, + child: SizedBox( + width: touchSize, + height: touchSize, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: _hovered ? bgHover : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Center( + child: IconTheme( + data: IconThemeData(color: color, size: 16), + child: widget.icon, + ), + ), + ), + ), + ), + ), + ); + + if (widget.tooltip != null) { + return Tooltip( + message: widget.tooltip!, + waitDuration: const Duration(milliseconds: 500), + child: btn, + ); + } + return btn; + } +} diff --git a/lib/widgets/mxc_image.dart b/lib/widgets/mxc_image.dart index ef0a585..809818f 100644 --- a/lib/widgets/mxc_image.dart +++ b/lib/widgets/mxc_image.dart @@ -1,53 +1,205 @@ +import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; -import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/media_cache.dart'; -class MxcImage extends ConsumerWidget { +class MxcAvatar extends StatefulWidget { final Uri? mxcUri; + final Client client; + final double size; + final BorderRadius? borderRadius; + final Widget Function(BuildContext) placeholder; + + const MxcAvatar({ + super.key, + required this.mxcUri, + required this.client, + required this.size, + required this.placeholder, + this.borderRadius, + }); + + @override + State createState() => _MxcAvatarState(); +} + +class _MxcAvatarState extends State { + late Future _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + @override + void didUpdateWidget(MxcAvatar old) { + super.didUpdateWidget(old); + if (old.mxcUri != widget.mxcUri) _future = _load(); + } + + Future _load() async { + final mxc = widget.mxcUri; + if (mxc == null) return null; + + final key = 'avatar:${mxc}_${widget.size.round()}'; + // Memory → Disk → Netzwerk. Disk-Treffer machen Avatare nach einem + // App-Neustart sofort sichtbar statt nachzuladen. + final hit = await MediaCache.instance.getPersistent(key); + if (hit != null) return hit; + + final headers = {'authorization': 'Bearer ${widget.client.accessToken}'}; + + try { + // Try thumbnail first; fall back to full download if 404 (server + // doesn't support on-the-fly thumbnailing, e.g. Continuwuity). + final thumbUri = await mxc.getThumbnailUri( + widget.client, + width: widget.size.round(), + height: widget.size.round(), + method: ThumbnailMethod.crop, + ); + final thumbResp = await widget.client.httpClient.get(thumbUri, headers: headers); + if (thumbResp.statusCode == 200) { + await MediaCache.instance.putPersistent(key, thumbResp.bodyBytes); + return thumbResp.bodyBytes; + } + + // Thumbnail not available — download full image. + final dlUri = await mxc.getDownloadUri(widget.client); + final dlResp = await widget.client.httpClient.get(dlUri, headers: headers); + if (dlResp.statusCode != 200) return null; + await MediaCache.instance.putPersistent(key, dlResp.bodyBytes); + return dlResp.bodyBytes; + } catch (_) { + return null; + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.hasData && snap.data != null) { + return ClipRRect( + borderRadius: widget.borderRadius ?? + BorderRadius.circular(widget.size / 2), + child: Image.memory( + snap.data!, + width: widget.size, + height: widget.size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => widget.placeholder(context), + ), + ); + } + return widget.placeholder(context); + }, + ); + } +} + +// Rectangular MXC image (no fixed size, fills parent) +class MxcImage extends StatefulWidget { + final String mxcUri; + final Client client; + final BoxFit fit; final double? width; final double? height; - final Widget Function(BuildContext)? placeholder; + final Widget? placeholder; const MxcImage({ super.key, required this.mxcUri, + required this.client, + this.fit = BoxFit.cover, this.width, this.height, this.placeholder, }); @override - Widget build(BuildContext context, WidgetRef ref) { - final clientAsync = ref.watch(matrixClientProvider); - final uri = mxcUri; + State createState() => _MxcImageState(); +} - if (uri == null) return _fallback(context); +class _MxcImageState extends State { + late Future _future; - return clientAsync.when( - loading: () => _fallback(context), - error: (e, st) => _fallback(context), - data: (client) => FutureBuilder( - future: uri.getThumbnailUri( - client, - width: width != null ? (width! * 2).toInt() : 64, - height: height != null ? (height! * 2).toInt() : 64, - method: ThumbnailMethod.crop, - ), - builder: (context, snap) { - if (!snap.hasData) return _fallback(context); - return Image.network( - snap.data!.toString(), - width: width, - height: height, - fit: BoxFit.cover, - errorBuilder: (ctx, err, st) => _fallback(context), - ); - }, - ), - ); + @override + void initState() { + super.initState(); + _future = _load(); } - Widget _fallback(BuildContext context) => - placeholder?.call(context) ?? SizedBox(width: width, height: height); + @override + void didUpdateWidget(MxcImage old) { + super.didUpdateWidget(old); + if (old.mxcUri != widget.mxcUri) { + // Block-Body: die Closure darf KEIN Future zurückgeben, sonst wirft + // Flutter "setState() callback returned a Future". + setState(() { + _future = _load(); + }); + } + } + + Future _load() async { + final key = 'img:${widget.mxcUri}'; + final hit = await MediaCache.instance.getPersistent(key); + if (hit != null) return hit; + try { + final uri = Uri.parse(widget.mxcUri); + final headers = { + 'authorization': 'Bearer ${widget.client.accessToken}', + }; + // Erst Thumbnail versuchen (spart Bandbreite, v.a. mobil) — Fallback + // auf Volldownload für Server ohne Thumbnail-Support (Continuwuity). + try { + final thumbUri = await uri.getThumbnailUri( + widget.client, + width: 800, + height: 600, + method: ThumbnailMethod.scale, + ); + final res = await widget.client.httpClient.get(thumbUri, headers: headers); + if (res.statusCode == 200) { + await MediaCache.instance.putPersistent(key, res.bodyBytes); + return res.bodyBytes; + } + } catch (_) {} + final httpUri = await uri.getDownloadUri(widget.client); + final res = await widget.client.httpClient.get(httpUri, headers: headers); + if (res.statusCode != 200) return null; + final bytes = res.bodyBytes; + await MediaCache.instance.putPersistent(key, bytes); + return bytes; + } catch (_) { + return null; + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (_, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return widget.placeholder ?? const SizedBox.shrink(); + } + if (snap.hasData && snap.data != null) { + return Image.memory( + snap.data!, + fit: widget.fit, + width: widget.width, + height: widget.height, + gaplessPlayback: true, + errorBuilder: (_, __, ___) => + widget.placeholder ?? const SizedBox.shrink(), + ); + } + return widget.placeholder ?? const SizedBox.shrink(); + }, + ); + } } diff --git a/lib/widgets/new_dm_dialog.dart b/lib/widgets/new_dm_dialog.dart new file mode 100644 index 0000000..33801a0 --- /dev/null +++ b/lib/widgets/new_dm_dialog.dart @@ -0,0 +1,346 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; + +class NewDmDialog extends ConsumerStatefulWidget { + const NewDmDialog({super.key}); + + @override + ConsumerState createState() => _NewDmDialogState(); +} + +class _NewDmDialogState extends ConsumerState { + final _searchCtrl = TextEditingController(); + List _results = []; + bool _searching = false; + String? _error; + Timer? _debounce; + final Set _starting = {}; + + @override + void dispose() { + _searchCtrl.dispose(); + _debounce?.cancel(); + super.dispose(); + } + + void _onSearchChanged(String query) { + _debounce?.cancel(); + if (query.trim().length < 2) { + setState(() { + _results = []; + _searching = false; + _error = null; + }); + return; + } + setState(() => _searching = true); + _debounce = Timer(const Duration(milliseconds: 500), () => _search(query.trim())); + } + + Future _search(String query) async { + try { + final client = await ref.read(matrixClientProvider.future); + final res = await client.searchUserDirectory(query, limit: 20); + if (mounted) { + setState(() { + _results = res.results; + _searching = false; + _error = null; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _searching = false; + _error = e.toString().split('\n').first; + }); + } + } + } + + Future _startDm(String userId) async { + setState(() => _starting.add(userId)); + try { + final client = await ref.read(matrixClientProvider.future); + final roomId = await client.startDirectChat(userId); + if (mounted) { + ref.read(activeSpaceIdProvider.notifier).state = 'dms'; + ref.read(activeRoomIdProvider.notifier).state = roomId; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + setState(() { + _starting.remove(userId); + _error = e.toString().split('\n').first; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final size = MediaQuery.sizeOf(context); + final isNarrow = size.width < 600; + + return Dialog( + backgroundColor: pt.bg1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isNarrow ? size.width * 0.97 : 480, + maxHeight: size.height * 0.75, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 12, 12), + child: Row(children: [ + Icon(Icons.chat_bubble_outline_rounded, size: 16, color: pt.accent), + const SizedBox(width: 8), + Expanded( + child: Text('Neue Unterhaltung', + style: TextStyle( + color: pt.fg, + fontSize: 15, + fontWeight: FontWeight.w600)), + ), + IconButton( + icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + ), + ]), + ), + Divider(color: pt.border, height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: TextField( + controller: _searchCtrl, + autofocus: true, + style: TextStyle(color: pt.fg, fontSize: 13), + onChanged: _onSearchChanged, + decoration: InputDecoration( + hintText: 'Name oder @nutzer:server suchen', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), + prefixIcon: Padding( + padding: const EdgeInsets.only(left: 10, right: 8), + child: Icon(Icons.search_rounded, size: 16, color: pt.fgDim), + ), + prefixIconConstraints: + const BoxConstraints(minWidth: 0, minHeight: 0), + filled: true, + fillColor: pt.bg2, + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + 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, + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: _DmErrorBanner(text: _error!, pt: pt), + ), + Flexible( + child: _searching + ? const Padding( + padding: EdgeInsets.all(24), + child: + Center(child: CircularProgressIndicator.adaptive()), + ) + : _results.isEmpty + ? Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Text( + _searchCtrl.text.trim().length < 2 + ? 'Mindestens 2 Zeichen eingeben.' + : 'Keine Nutzer gefunden.', + style: TextStyle( + color: pt.fgMuted, fontSize: 13), + ), + ), + ) + : ListView.builder( + shrinkWrap: true, + padding: + const EdgeInsets.fromLTRB(8, 0, 8, 12), + itemCount: _results.length, + itemBuilder: (ctx, i) { + final user = _results[i]; + return _UserResultItem( + profile: user, + pt: pt, + isStarting: + _starting.contains(user.userId), + onTap: () => _startDm(user.userId), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _UserResultItem extends ConsumerWidget { + final Profile profile; + final PyramidTheme pt; + final bool isStarting; + final VoidCallback onTap; + + const _UserResultItem({ + required this.profile, + required this.pt, + required this.isStarting, + required this.onTap, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final client = ref.watch(matrixClientProvider).valueOrNull; + final name = profile.displayName ?? profile.userId; + final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; + + Widget avatar; + if (client != null && profile.avatarUrl != null) { + avatar = MxcAvatar( + mxcUri: profile.avatarUrl, + client: client, + size: 36, + borderRadius: BorderRadius.circular(18), + placeholder: (_) => + _DmInitialCircle(initial: initial, size: 36, name: name), + ); + } else { + avatar = _DmInitialCircle(initial: initial, size: 36, name: name); + } + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: isStarting ? null : onTap, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row(children: [ + avatar, + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis), + Text(profile.userId, + style: + TextStyle(color: pt.fgDim, fontSize: 11), + overflow: TextOverflow.ellipsis), + ]), + ), + const SizedBox(width: 8), + if (isStarting) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2)) + else + Icon(Icons.arrow_forward_rounded, + size: 14, color: pt.fgDim), + ]), + ), + ), + ); + } +} + +class _DmInitialCircle extends StatelessWidget { + final String initial; + final double size; + final String name; + + const _DmInitialCircle( + {required this.initial, required this.size, required this.name}); + + Color _color() { + 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]; + } + + @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: 14, + fontWeight: FontWeight.w600)), + ), + ); + } +} + +class _DmErrorBanner extends StatelessWidget { + final String text; + final PyramidTheme pt; + const _DmErrorBanner({required this.text, required this.pt}); + + @override + Widget build(BuildContext context) => Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: pt.danger.withAlpha(20), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: pt.danger.withAlpha(60)), + ), + child: Row(children: [ + Icon(Icons.error_outline_rounded, size: 14, color: pt.danger), + const SizedBox(width: 8), + Expanded( + child: Text(text, + style: + TextStyle(color: pt.danger, fontSize: 12))), + ]), + ); +} diff --git a/lib/widgets/profile_popover.dart b/lib/widgets/profile_popover.dart new file mode 100644 index 0000000..fded0d5 --- /dev/null +++ b/lib/widgets/profile_popover.dart @@ -0,0 +1,442 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; + +class ProfilePopover extends StatefulWidget { + final String userId; + final Room room; + final Offset globalPos; + final VoidCallback onClose; + + const ProfilePopover({ + super.key, + required this.userId, + required this.room, + required this.globalPos, + required this.onClose, + }); + + @override + State createState() => _ProfilePopoverState(); +} + +class _ProfilePopoverState extends State { + Profile? _profile; + PresenceType? _presence; + String? _bannerMxcUri; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final p = await widget.room.client.getProfileFromUserId(widget.userId); + if (mounted) setState(() => _profile = p); + } catch (_) {} + try { + final p = await widget.room.client.fetchCurrentPresence(widget.userId); + // Veraltete "online"-Presence herabstufen, wenn die letzte Aktivität + // nicht frisch ist (Server meldet sonst dauerhaft online). + final last = p.lastActiveTimestamp; + final fresh = p.currentlyActive == true || + (last != null && + DateTime.now().difference(last) < const Duration(minutes: 5)); + final effective = (p.presence == PresenceType.online && !fresh) + ? PresenceType.offline + : p.presence; + if (mounted) setState(() => _presence = effective); + } catch (_) {} + // Load banner from Matrix profile custom field (works for any user) + try { + final client = widget.room.client; + final apiUri = client.homeserver!.replace( + path: '/_matrix/client/v3/profile/${Uri.encodeComponent(widget.userId)}', + ); + final resp = await client.httpClient.get( + apiUri, + headers: {'Authorization': 'Bearer ${client.accessToken}'}, + ); + if (resp.statusCode == 200) { + final data = jsonDecode(resp.body) as Map; + final url = data['io.pyramid.profile.banner_url'] as String?; + if (url != null && mounted) setState(() => _bannerMxcUri = url); + } + } catch (_) {} + // Legacy fallback: account data (only own user) + if (_bannerMxcUri == null && widget.userId == widget.room.client.userID) { + try { + final bannerData = + widget.room.client.accountData['com.pyramid.profile.banner']; + final url = bannerData?.content['url'] as String?; + if (url != null && mounted) setState(() => _bannerMxcUri = url); + } catch (_) {} + } + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final member = widget.room.unsafeGetUserFromMemoryOrFallback(widget.userId); + final displayName = _profile?.displayName ?? member.displayName ?? widget.userId.split(':').first.replaceAll('@', ''); + final avatarUrl = _profile?.avatarUrl ?? member.avatarUrl; + final handle = widget.userId; + final initials = displayName.isNotEmpty ? displayName[0].toUpperCase() : '?'; + final color = _colorForName(displayName); + final powerLevel = widget.room.getPowerLevelByUserId(widget.userId); + final joinDate = _formatJoinDate(widget.room, widget.userId); + + const popoverW = 300.0; + const popoverH = 380.0; + final left = (widget.globalPos.dx + 16).clamp(8.0, size.width - popoverW - 8); + final top = (widget.globalPos.dy - 80).clamp(8.0, size.height - popoverH - 8); + + return Positioned( + left: left, + top: top, + child: Material( + color: Colors.transparent, + child: Container( + width: popoverW, + decoration: BoxDecoration( + color: PyramidColors.bg0, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: PyramidColors.border), + boxShadow: const [ + BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8)) + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Banner — real image for own profile, color gradient for others + SizedBox( + height: 64, + child: _bannerMxcUri != null + ? MxcImage( + mxcUri: _bannerMxcUri!, + client: widget.room.client, + fit: BoxFit.cover, + width: double.infinity, + height: 64, + placeholder: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + color.withValues(alpha: 0.6), + color.withValues(alpha: 0.3) + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ), + ) + : Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + color.withValues(alpha: 0.6), + color.withValues(alpha: 0.3) + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar row + Transform.translate( + offset: const Offset(0, -28), + child: Row( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: PyramidColors.bg0, width: 3), + ), + clipBehavior: Clip.antiAlias, + child: avatarUrl != null + ? MxcImage( + mxcUri: avatarUrl.toString(), + client: widget.room.client, + width: 56, + height: 56, + placeholder: Center( + child: Text(initials, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w700)), + ), + ) + : Center( + child: Text(initials, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w700))), + ), + Positioned( + right: -2, + bottom: -2, + child: _PresenceDot(presence: _presence, size: 14), + ), + ], + ), + ], + ), + ), + Transform.translate( + offset: const Offset(0, -20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(displayName, + style: const TextStyle( + color: PyramidColors.fg, + fontSize: 17, + fontWeight: FontWeight.w700)), + const SizedBox(height: 2), + Text(handle, + style: const TextStyle( + color: PyramidColors.fgMuted, fontSize: 12)), + const SizedBox(height: 12), + // Action buttons + Row( + children: [ + _ProfileBtn( + label: 'Nachricht', + icon: Icons.send_outlined, + primary: true, + onTap: () { + widget.onClose(); + // TODO: open DM + }, + ), + const SizedBox(width: 8), + _ProfileBtn( + label: 'Anrufen', + icon: Icons.call_outlined, + onTap: () {}, + ), + const SizedBox(width: 8), + _ProfileBtn( + icon: Icons.more_vert, + onTap: () {}, + ), + ], + ), + if (powerLevel > 0) ...[ + const SizedBox(height: 14), + const Text('Rollen', + style: TextStyle( + color: PyramidColors.fgDim, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.8)), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + _RoleBadge( + label: powerLevel >= 100 ? 'Admin' : 'Moderator', + color: powerLevel >= 100 + ? const Color(0xFFF59E0B) + : const Color(0xFF06B6D4), + ), + ], + ), + ], + if (joinDate != null) ...[ + const SizedBox(height: 14), + const Text('Mitglied seit', + style: TextStyle( + color: PyramidColors.fgDim, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.8)), + const SizedBox(height: 4), + Text(joinDate, + style: const TextStyle( + color: PyramidColors.fgMuted, fontSize: 12)), + ], + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + String? _formatJoinDate(Room room, String userId) { + try { + final member = room.unsafeGetUserFromMemoryOrFallback(userId); + // User joined if membership is join + if (member.membership != Membership.join) return null; + // No timestamp available from StrippedStateEvent; show server domain hint + final server = userId.contains(':') ? userId.split(':').last : null; + return server != null ? 'via $server' : null; + } catch (_) { + return null; + } + } + + Color _colorForName(String name) { + const colors = [ + Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFFFF6B9D), + Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFF3B82F6), + Color(0xFFF43F5E), + ]; + final hash = name.codeUnits.fold(0, (a, b) => a + b); + return colors[hash % colors.length]; + } +} + +class _PresenceDot extends StatelessWidget { + final PresenceType? presence; + final double size; + const _PresenceDot({required this.presence, required this.size}); + + @override + Widget build(BuildContext context) { + final color = switch (presence) { + PresenceType.online => const Color(0xFF23A55A), + PresenceType.unavailable => const Color(0xFFF0B232), + _ => const Color(0xFF7C7C8A), + }; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: PyramidColors.bg0, width: 2), + ), + ); + } +} + +class _ProfileBtn extends StatefulWidget { + final String? label; + final IconData icon; + final bool primary; + final VoidCallback onTap; + + const _ProfileBtn({ + required this.icon, + required this.onTap, + this.label, + this.primary = false, + }); + + @override + State<_ProfileBtn> createState() => _ProfileBtnState(); +} + +class _ProfileBtnState extends State<_ProfileBtn> { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final hasLabel = widget.label != null; + return MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: EdgeInsets.symmetric( + horizontal: hasLabel ? 12 : 9, vertical: 7), + decoration: BoxDecoration( + color: widget.primary + ? (_hover + ? PyramidTheme.of(context).accent.withAlpha(200) + : PyramidTheme.of(context).accent) + : (_hover ? PyramidColors.bgHover : PyramidColors.bg2), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: widget.primary ? Colors.transparent : PyramidColors.border, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon, + size: 14, + color: widget.primary + ? PyramidColors.accentFg + : PyramidColors.fg), + if (hasLabel) ...[ + const SizedBox(width: 6), + Text(widget.label!, + style: TextStyle( + color: widget.primary + ? PyramidColors.accentFg + : PyramidColors.fg, + fontSize: 13, + fontWeight: FontWeight.w500)), + ], + ], + ), + ), + ), + ); + } +} + +class _RoleBadge extends StatelessWidget { + final String label; + final Color color; + const _RoleBadge({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 5), + Text(label, + style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w500)), + ], + ), + ); + } +} diff --git a/lib/widgets/pyramid_loader.dart b/lib/widgets/pyramid_loader.dart new file mode 100644 index 0000000..e78431e --- /dev/null +++ b/lib/widgets/pyramid_loader.dart @@ -0,0 +1,202 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; +import 'package:pyramid/core/theme.dart'; + +class PyramidLoader extends StatefulWidget { + final double size; + final bool useLottie; + + const PyramidLoader({super.key, this.size = 60, this.useLottie = false}); + + @override + State createState() => _PyramidLoaderState(); +} + +class _PyramidLoaderState extends State + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + + // Mirror the JS constants exactly + static const _hold = 0.2; + static const _curveExp = 3.0; + static const _tiltDeg = 20.0; + + double _ease(double t, double exp) => + t < 0.5 ? 0.5 * pow(2 * t, exp) : 1 - 0.5 * pow(2 * (1 - t), exp); + + double _yawAt(double t) { + final a = 1.0 - _hold; + if (t >= a) return pi / 4; + return _ease(t / a, _curveExp) * 2 * pi + pi / 4; + } + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2000), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + Color _accent(BuildContext ctx) { + try { + return PyramidTheme.of(ctx).accent; + } catch (_) { + return const Color(0xFFF5A524); + } + } + + @override + Widget build(BuildContext context) { + final accent = _accent(context); + + final painter = AnimatedBuilder( + animation: _ctrl, + builder: (_, __) => CustomPaint( + painter: _PyramidPainter( + yaw: _yawAt(_ctrl.value), + accent: accent, + tiltDeg: _tiltDeg, + ), + size: Size(widget.size, widget.size), + ), + ); + + if (widget.useLottie) { + return Center( + child: SizedBox( + width: widget.size, + height: widget.size, + child: ColorFiltered( + colorFilter: ColorFilter.mode(accent, BlendMode.srcIn), + child: Lottie.asset( + 'assets/pyramid-loader.json', + fit: BoxFit.contain, + repeat: true, + animate: true, + errorBuilder: (_, __, ___) => painter, + ), + ), + ), + ); + } + + return Center(child: painter); + } +} + +class _PyramidPainter extends CustomPainter { + final double yaw; + final Color accent; + final double tiltDeg; + + const _PyramidPainter({ + required this.yaw, + required this.accent, + required this.tiltDeg, + }); + + List _rotY(List v, double a) { + final c = cos(a), s = sin(a); + return [v[0] * c + v[2] * s, v[1], -v[0] * s + v[2] * c]; + } + + List> _clipZ(List> poly) { + final out = >[]; + for (var i = 0; i < poly.length; i++) { + final a = poly[i]; + final b = poly[(i + 1) % poly.length]; + final ai = a[2] >= 0, bi = b[2] >= 0; + if (ai) out.add(a); + if (ai != bi) { + final t = a[2] / (a[2] - b[2]); + out.add([a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]), 0.0]); + } + } + return out; + } + + Offset _proj(List v, double sc, double cx, double cy, double tr) => + Offset(cx + v[0] * sc, cy + (v[1] * cos(tr) + v[2] * sin(tr)) * sc); + + double _frontness(List> verts) { + final a = verts[0], b = verts[1], c = verts[2]; + final ux = b[0]-a[0], uy = b[1]-a[1], uz = b[2]-a[2]; + final vx = c[0]-a[0], vy = c[1]-a[1], vz = c[2]-a[2]; + final nz = ux * vy - uy * vx; + final nLen = sqrt( + pow(uy * vz - uz * vy, 2) + pow(uz * vx - ux * vz, 2) + nz * nz, + ); + return nLen == 0 ? 0 : max(0.0, -nz / nLen); + } + + Color _darken(Color c, double amount) => Color.fromARGB( + c.alpha, + max(0, (c.red * (1 - amount)).round()), + max(0, (c.green * (1 - amount)).round()), + max(0, (c.blue * (1 - amount)).round()), + ); + + @override + void paint(Canvas canvas, Size size) { + final sc = size.width * 0.38; + final cx = size.width / 2; + final cy = size.height / 2; + final tr = -tiltDeg * pi / 180; + + const base = 0.7; + final faces = [ + [[0.0, -1.0, 0.0], [base, 1.0, base], [base, 1.0, -base]], + [[0.0, -1.0, 0.0], [base, 1.0, -base], [-base, 1.0, -base]], + [[0.0, -1.0, 0.0], [-base, 1.0, -base],[-base, 1.0, base]], + [[0.0, -1.0, 0.0], [-base, 1.0, base], [base, 1.0, base]], + ]; + + final items = <({List pts, double depth})>[]; + + for (final face in faces) { + final rot = face.map((v) => _rotY(v, yaw)).toList(); + final cl = _clipZ(rot); + if (cl.length < 3) continue; + final depth = cl.fold(0.0, (s, v) => s + v[2]) / cl.length; + items.add(( + pts: cl.map((v) => _proj(v, sc, cx, cy, tr)).toList(), + depth: depth, + )); + } + + items.sort((a, b) => a.depth.compareTo(b.depth)); + + final fillPaint = Paint() + ..color = _darken(accent, 0.45) + ..style = PaintingStyle.fill; + final strokePaint = Paint() + ..color = accent + ..style = PaintingStyle.stroke + ..strokeWidth = 1.4 * (size.width / 96) + ..strokeJoin = StrokeJoin.round + ..strokeCap = StrokeCap.round; + + for (final item in items) { + final path = Path()..moveTo(item.pts[0].dx, item.pts[0].dy); + for (var i = 1; i < item.pts.length; i++) { + path.lineTo(item.pts[i].dx, item.pts[i].dy); + } + path.close(); + canvas.drawPath(path, fillPaint); + canvas.drawPath(path, strokePaint); + } + } + + @override + bool shouldRepaint(_PyramidPainter old) => + old.yaw != yaw || old.accent != accent; +} diff --git a/lib/widgets/pyramid_logo.dart b/lib/widgets/pyramid_logo.dart new file mode 100644 index 0000000..c1a344b --- /dev/null +++ b/lib/widgets/pyramid_logo.dart @@ -0,0 +1,187 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; + +// 3D Pyramid logo — two faces + base outline + spine +class PyramidLogo extends StatelessWidget { + final double size; + final Color color; + const PyramidLogo({super.key, this.size = 36, this.color = Colors.white}); + + @override + Widget build(BuildContext context) => + CustomPaint(size: Size(size, size), painter: _PyramidLogoPainter(color: color)); +} + +class _PyramidLogoPainter extends CustomPainter { + final Color color; + const _PyramidLogoPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + final sx = w / 48; + final sy = h / 48; + Offset p(double x, double y) => Offset(x * sx, y * sy); + + final apex = p(24, 6); + final baseL = p(6, 40); + final baseR = p(42, 40); + final baseM = p(24, 34); + + // Left face — full opacity → 55% + final leftPath = Path()..moveTo(apex.dx, apex.dy)..lineTo(baseL.dx, baseL.dy)..lineTo(baseM.dx, baseM.dy)..close(); + canvas.drawPath(leftPath, Paint()..shader = ui.Gradient.linear( + Offset(0, apex.dy), Offset(0, baseL.dy), + [color.withAlpha(255), color.withAlpha(140)], + )); + + // Right face — 85% → 25% + final rightPath = Path()..moveTo(apex.dx, apex.dy)..lineTo(baseR.dx, baseR.dy)..lineTo(baseM.dx, baseM.dy)..close(); + canvas.drawPath(rightPath, Paint()..shader = ui.Gradient.linear( + Offset(apex.dx, apex.dy), Offset(baseR.dx, baseR.dy), + [color.withAlpha(217), color.withAlpha(64)], + )); + + // Base outline + canvas.drawPath( + Path()..moveTo(baseL.dx, baseL.dy)..lineTo(baseM.dx, baseM.dy)..lineTo(baseR.dx, baseR.dy), + Paint()..color = color.withAlpha(102)..style = PaintingStyle.stroke + ..strokeWidth = math.max(sx, 0.7)..strokeJoin = StrokeJoin.round, + ); + + // Spine + canvas.drawLine(apex, baseM, Paint()..color = color.withAlpha(230) + ..strokeWidth = math.max(sx * 0.8, 0.6)); + } + + @override + bool shouldRepaint(_PyramidLogoPainter old) => old.color != color; +} + +// Pyramid mark — stylised stacked triangles +class PyramidMark extends StatelessWidget { + final double size; + final Color? color; + + const PyramidMark({super.key, this.size = 30, this.color}); + + @override + Widget build(BuildContext context) { + final c = color ?? Colors.white; + return SizedBox( + width: size, + height: size, + child: CustomPaint(painter: _PyramidPainter(c)), + ); + } +} + +class _PyramidPainter extends CustomPainter { + final Color color; + _PyramidPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = color..style = PaintingStyle.fill; + final w = size.width; + final h = size.height; + + // bottom big triangle + final big = Path() + ..moveTo(w * 0.5, h * 0.1) + ..lineTo(w * 0.92, h * 0.88) + ..lineTo(w * 0.08, h * 0.88) + ..close(); + canvas.drawPath(big, paint); + + // inner cutout (dark) to create layered look + final cut = Paint() + ..color = Colors.black.withAlpha(80) + ..style = PaintingStyle.fill; + final inner = Path() + ..moveTo(w * 0.5, h * 0.28) + ..lineTo(w * 0.74, h * 0.72) + ..lineTo(w * 0.26, h * 0.72) + ..close(); + canvas.drawPath(inner, cut); + } + + @override + bool shouldRepaint(_PyramidPainter old) => old.color != color; +} + +// Pyramid space glyph (letter + triangle overlay) +class PyramidGlyph extends StatelessWidget { + final double size; + final Color color; + final String label; + + const PyramidGlyph({ + super.key, + this.size = 30, + required this.color, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + size: Size(size, size), + painter: _PyramidPainter(color), + ), + Text( + label.isNotEmpty ? label[0].toUpperCase() : '?', + style: TextStyle( + color: Colors.white, + fontSize: size * 0.38, + fontWeight: FontWeight.bold, + height: 1, + ), + ), + ], + ), + ); + } +} + +// Presence indicator dot +class PresenceDot extends StatelessWidget { + final String status; // online, away, busy, offline + final double size; + final Color borderColor; + + const PresenceDot({ + super.key, + required this.status, + this.size = 10, + this.borderColor = const Color(0xFF111114), + }); + + Color get _color => switch (status) { + 'online' => const Color(0xFF4ADE80), + 'away' => const Color(0xFFFACC15), + 'busy' => const Color(0xFFF87171), + _ => const Color(0xFF6F6F7D), + }; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: _color, + shape: BoxShape.circle, + border: Border.all(color: borderColor, width: 2), + ), + ); + } +} diff --git a/lib/widgets/screen_share_picker.dart b/lib/widgets/screen_share_picker.dart new file mode 100644 index 0000000..03704f4 --- /dev/null +++ b/lib/widgets/screen_share_picker.dart @@ -0,0 +1,233 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:pyramid/core/theme.dart'; + +class ScreenSharePicker extends StatefulWidget { + const ScreenSharePicker({super.key}); + + static Future show(BuildContext context) async { + return showDialog( + context: context, + builder: (context) => const ScreenSharePicker(), + ); + } + + @override + State createState() => _ScreenSharePickerState(); +} + +class _ScreenSharePickerState extends State with SingleTickerProviderStateMixin { + late TabController _tabController; + List _sources = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _loadSources(); + } + + Future _loadSources() async { + try { + // FluffyChat Fix: Get both types at once to avoid native crashes on Windows + final sources = await desktopCapturer.getSources(types: [ + SourceType.Screen, + SourceType.Window, + ]); + if (mounted) { + setState(() { + _sources = sources; + _loading = false; + }); + } + } catch (e) { + print('[Picker] Error loading sources: $e'); + if (mounted) setState(() => _loading = false); + } + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final screens = _sources.where((s) => s.type == SourceType.Screen).toList(); + final windows = _sources.where((s) => s.type == SourceType.Window).toList(); + + final screenSize = MediaQuery.sizeOf(context); + return Center( + child: Container( + // Auf kleinen Bildschirmen (Mobile, Quer- wie Hochformat) einpassen. + width: (screenSize.width - 32).clamp(280.0, 600.0), + height: (screenSize.height - 48).clamp(320.0, 520.0), + decoration: BoxDecoration( + color: pt.bg1, + borderRadius: BorderRadius.circular(pt.rXl), + border: Border.all(color: pt.border), + boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 40)], + ), + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 8), + child: Row( + children: [ + Text('Share your screen', + style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)), + const Spacer(), + IconButton( + icon: Icon(Icons.close_rounded, color: pt.fgDim), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + // Tabs + TabBar( + controller: _tabController, + indicatorColor: pt.accent, + labelColor: pt.accent, + unselectedLabelColor: pt.fgMuted, + dividerColor: pt.border, + tabs: const [ + Tab(text: 'Screens'), + Tab(text: 'Windows'), + ], + ), + + // Content + Expanded( + child: _loading + ? Center(child: CircularProgressIndicator(color: pt.accent)) + : TabBarView( + controller: _tabController, + children: [ + _SourceGrid(sources: screens, pt: pt), + _SourceGrid(sources: windows, pt: pt), + ], + ), + ), + + // Footer + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.vertical(bottom: Radius.circular(pt.rXl)), + border: Border(top: BorderSide(color: pt.border)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Cancel', style: TextStyle(color: pt.fgMuted)), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _SourceGrid extends StatelessWidget { + final List sources; + final PyramidTheme pt; + + const _SourceGrid({required this.sources, required this.pt}); + + @override + Widget build(BuildContext context) { + if (sources.isEmpty) { + return Center(child: Text('No sources found', style: TextStyle(color: pt.fgDim))); + } + + return GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + childAspectRatio: 1.4, + ), + itemCount: sources.length, + itemBuilder: (context, i) { + final s = sources[i]; + return _SourceTile(source: s, pt: pt); + }, + ); + } +} + +class _SourceTile extends StatefulWidget { + final DesktopCapturerSource source; + final PyramidTheme pt; + + const _SourceTile({required this.source, required this.pt}); + + @override + State<_SourceTile> createState() => _SourceTileState(); +} + +class _SourceTileState extends State<_SourceTile> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => Navigator.pop(context, widget.source), + child: Column( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all( + color: _hovered ? pt.accent : pt.border, + width: _hovered ? 2 : 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: widget.source.thumbnail != null + ? Image.memory( + widget.source.thumbnail!, + fit: BoxFit.cover, + width: double.infinity, + ) + : Center(child: Icon(Icons.monitor_rounded, color: pt.fgDim, size: 32)), + ), + ), + const SizedBox(height: 6), + Text( + widget.source.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: _hovered ? pt.fg : pt.fgMuted, + fontSize: 12, + fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/search_modal.dart b/lib/widgets/search_modal.dart new file mode 100644 index 0000000..3679c05 --- /dev/null +++ b/lib/widgets/search_modal.dart @@ -0,0 +1,840 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; + +// ─── State ──────────────────────────────────────────────────────────────────── + +enum _Filter { all, media, people } + +class _SearchState { + final bool loading; + final List messageResults; + final List roomResults; + final String? error; + + const _SearchState({ + this.loading = false, + this.messageResults = const [], + this.roomResults = const [], + this.error, + }); + + _SearchState copyWith({ + bool? loading, + List? messageResults, + List? roomResults, + String? error, + }) => _SearchState( + loading: loading ?? this.loading, + messageResults: messageResults ?? this.messageResults, + roomResults: roomResults ?? this.roomResults, + error: error, + ); +} + +// ─── Widget ─────────────────────────────────────────────────────────────────── + +class SearchModal extends ConsumerStatefulWidget { + final VoidCallback onClose; + const SearchModal({super.key, required this.onClose}); + + @override + ConsumerState createState() => _SearchModalState(); +} + +class _SearchModalState extends ConsumerState { + final _ctrl = TextEditingController(); + final _focusNode = FocusNode(); + _Filter _filter = _Filter.all; + String _query = ''; + int _selected = 0; + _SearchState _state = const _SearchState(); + Timer? _debounce; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus()); + } + + @override + void dispose() { + _debounce?.cancel(); + _ctrl.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _onQueryChanged(String q) { + setState(() { + _query = q; + _selected = 0; + }); + _debounce?.cancel(); + if (q.trim().isEmpty) { + setState(() => _state = const _SearchState()); + return; + } + _debounce = Timer(const Duration(milliseconds: 400), () => _runSearch(q.trim())); + } + + Future _runSearch(String q) async { + final client = ref.read(matrixClientProvider).valueOrNull; + if (client == null) return; + setState(() => _state = _state.copyWith(loading: true, error: null)); + + // Room/people filter: purely local + final allRooms = client.rooms.where((r) => !r.isSpace).toList(); + final roomResults = _filter == _Filter.media + ? [] + : allRooms + .where((r) { + if (_filter == _Filter.people && !r.isDirectChat) return false; + if (_filter == _Filter.all && r.isDirectChat) return false; + return r.getLocalizedDisplayname().toLowerCase().contains(q.toLowerCase()); + }) + .take(5) + .toList(); + + // Message/media search: LOCAL over decrypted events. The server-side + // /search API can't see E2EE message content (only ciphertext), so it + // returns nothing for encrypted rooms — we search the local database + // (which holds decrypted events) instead, like Element does. + List messageResults = []; + if (_filter != _Filter.people) { + try { + messageResults = await _localMessageSearch(client, q); + } catch (e) { + setState(() => _state = _state.copyWith( + loading: false, + error: 'Suche fehlgeschlagen: $e', + )); + return; + } + } + + if (mounted) { + setState(() => _state = _SearchState( + loading: false, + messageResults: messageResults, + roomResults: roomResults, + )); + } + } + + // Scans the local (decrypted) event database across all rooms for messages + // whose body matches the query. For the media filter, only media messages + // whose filename/caption matches are returned. + Future> _localMessageSearch(Client client, String q) async { + final db = client.database; + final lower = q.toLowerCase(); + final media = _filter == _Filter.media; + const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'}; + const perRoomScanCap = 1500; + const totalCap = 60; + const chunk = 500; + + final results = []; + for (final room in client.rooms.where((r) => !r.isSpace)) { + var start = 0; + var scanned = 0; + try { + while (scanned < perRoomScanCap) { + final events = await db.getEventList(room, start: start, limit: chunk); + if (events.isEmpty) break; + for (final e in events) { + if (e.type != EventTypes.Message || e.redacted) continue; + final msgtype = e.content.tryGet('msgtype') ?? ''; + if (media && !mediaTypes.contains(msgtype)) continue; + if (!e.body.toLowerCase().contains(lower)) continue; + results.add(e); + } + start += chunk; + scanned += events.length; + if (events.length < chunk || results.length >= totalCap) break; + } + } catch (_) {} + if (results.length >= totalCap) break; + } + + results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs)); + return results.take(totalCap).toList(); + } + + void _openRoom(String roomId, {String? eventId}) { + widget.onClose(); + ref.read(activeRoomIdProvider.notifier).state = roomId; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + if (eventId != null) { + ref.read(pendingJumpEventProvider.notifier).state = eventId; + } + } + + int get _totalResults => + _state.roomResults.length + _state.messageResults.length; + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final client = ref.watch(matrixClientProvider).valueOrNull; + + final filters = [ + (_Filter.all, 'Alle', Icons.auto_awesome_rounded), + (_Filter.media, 'Medien', Icons.image_rounded), + (_Filter.people, 'Personen & Räume', Icons.group_rounded), + ]; + + return KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (e) { + if (e is! KeyDownEvent) return; + if (e.logicalKey == LogicalKeyboardKey.escape) widget.onClose(); + if (e.logicalKey == LogicalKeyboardKey.arrowDown) { + setState(() => _selected = (_selected + 1) % _totalResults.clamp(1, 999)); + } + if (e.logicalKey == LogicalKeyboardKey.arrowUp) { + setState(() => _selected = (_selected - 1).clamp(0, _totalResults - 1)); + } + if (e.logicalKey == LogicalKeyboardKey.enter) { + _activateSelected(client); + } + }, + child: GestureDetector( + onTap: widget.onClose, + child: Container( + color: Colors.black.withAlpha(150), + alignment: Alignment.topCenter, + padding: const EdgeInsets.only(top: 80), + child: GestureDetector( + onTap: () {}, + child: Container( + width: 640, + constraints: const BoxConstraints(maxHeight: 560), + decoration: BoxDecoration( + color: pt.bg1, + borderRadius: BorderRadius.circular(pt.rXl), + border: Border.all(color: pt.border), + boxShadow: [ + BoxShadow(color: Colors.black.withAlpha(180), blurRadius: 60, offset: const Offset(0, 20)), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Input + _SearchBar(ctrl: _ctrl, focusNode: _focusNode, onChanged: _onQueryChanged, pt: pt), + // Filters + _FilterRow( + filters: filters, + active: _filter, + pt: pt, + onSelect: (f) { + setState(() { _filter = f; _selected = 0; }); + if (_query.trim().isNotEmpty) _runSearch(_query.trim()); + }, + ), + // Results + Flexible(child: _ResultsPane( + state: _state, + query: _query, + filter: _filter, + selected: _selected, + pt: pt, + client: client, + onOpenRoom: _openRoom, + )), + // Footer + _Footer( + count: _totalResults, + loading: _state.loading, + pt: pt, + ), + ], + ), + ), + ), + ), + ), + ); + } + + void _activateSelected(Client? client) { + final roomCount = _state.roomResults.length; + if (_selected < roomCount) { + _openRoom(_state.roomResults[_selected].id); + } else { + final idx = _selected - roomCount; + if (idx < _state.messageResults.length) { + final e = _state.messageResults[idx]; + final rid = e.roomId; + if (rid != null) _openRoom(rid, eventId: e.eventId); + } + } + } +} + +// ─── Search bar ─────────────────────────────────────────────────────────────── + +class _SearchBar extends StatelessWidget { + final TextEditingController ctrl; + final FocusNode focusNode; + final ValueChanged onChanged; + final PyramidTheme pt; + const _SearchBar({required this.ctrl, required this.focusNode, required this.onChanged, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))), + child: Row( + children: [ + Icon(Icons.search_rounded, size: 20, color: pt.fgDim), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: ctrl, + focusNode: focusNode, + style: TextStyle(color: pt.fg, fontSize: 15), + cursorColor: pt.accent, + decoration: InputDecoration( + hintText: 'Nachrichten, Medien, Personen, Räume suchen…', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 15), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + onChanged: onChanged, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: pt.border), + ), + child: Text('esc', style: TextStyle(color: pt.fgMuted, fontSize: 11)), + ), + ], + ), + ); + } +} + +// ─── Filter row ─────────────────────────────────────────────────────────────── + +class _FilterRow extends StatelessWidget { + final List<(_Filter, String, IconData)> filters; + final _Filter active; + final PyramidTheme pt; + final ValueChanged<_Filter> onSelect; + const _FilterRow({required this.filters, required this.active, required this.pt, required this.onSelect}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: filters.map((f) => Padding( + padding: const EdgeInsets.only(right: 6), + child: _Chip(label: f.$2, icon: f.$3, active: active == f.$1, pt: pt, onTap: () => onSelect(f.$1)), + )).toList(), + ), + ), + ); + } +} + +// ─── Results pane ───────────────────────────────────────────────────────────── + +class _ResultsPane extends StatelessWidget { + final _SearchState state; + final String query; + final _Filter filter; + final int selected; + final PyramidTheme pt; + final Client? client; + final void Function(String roomId, {String? eventId}) onOpenRoom; + + const _ResultsPane({ + required this.state, + required this.query, + required this.filter, + required this.selected, + required this.pt, + required this.client, + required this.onOpenRoom, + }); + + @override + Widget build(BuildContext context) { + if (query.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.search_rounded, size: 36, color: pt.fgDim), + const SizedBox(height: 10), + Text('Tippe um zu suchen', style: TextStyle(color: pt.fgMuted, fontSize: 14)), + ]), + ), + ); + } + + if (state.loading) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), + ), + ); + } + + if (state.error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text(state.error!, style: TextStyle(color: pt.danger, fontSize: 13)), + ), + ); + } + + final hasRooms = state.roomResults.isNotEmpty; + final hasMessages = state.messageResults.isNotEmpty; + + if (!hasRooms && !hasMessages) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text('Keine Ergebnisse für "$query"', style: TextStyle(color: pt.fgMuted, fontSize: 14)), + ), + ); + } + + int itemIndex = 0; + final items = []; + + if (hasRooms) { + items.add(_SectionHeader(label: filter == _Filter.people ? 'Personen & Räume' : 'Räume', pt: pt)); + for (final room in state.roomResults) { + final idx = itemIndex++; + items.add(_RoomItem(room: room, selected: selected == idx, pt: pt, onTap: () => onOpenRoom(room.id))); + } + } + + if (hasMessages) { + items.add(_SectionHeader(label: filter == _Filter.media ? 'Medien' : 'Nachrichten', pt: pt)); + for (final event in state.messageResults) { + final idx = itemIndex++; + final room = client?.getRoomById(event.roomId ?? '') ?? event.room; + items.add(_MessageItem( + event: event, + room: room, + selected: selected == idx, + pt: pt, + onTap: () => onOpenRoom(event.roomId ?? '', eventId: event.eventId), + )); + } + } + + return ListView( + padding: const EdgeInsets.symmetric(vertical: 4), + shrinkWrap: true, + children: items, + ); + } +} + +// ─── Section header ─────────────────────────────────────────────────────────── + +class _SectionHeader extends StatelessWidget { + final String label; + final PyramidTheme pt; + const _SectionHeader({required this.label, required this.pt}); + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), + child: Text(label.toUpperCase(), style: TextStyle(color: pt.fgDim, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.8)), + ); +} + +// ─── Room result item ───────────────────────────────────────────────────────── + +class _RoomItem extends StatefulWidget { + final Room room; + final bool selected; + final PyramidTheme pt; + final VoidCallback onTap; + const _RoomItem({required this.room, required this.selected, required this.pt, required this.onTap}); + @override + State<_RoomItem> createState() => _RoomItemState(); +} + +class _RoomItemState extends State<_RoomItem> { + bool _hovered = false; + + Color _colorForName(String n) { + const c = [Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFFFF6B9D), Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFF3B82F6)]; + return c[n.codeUnits.fold(0, (a, b) => a + b) % c.length]; + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final room = widget.room; + final name = room.getLocalizedDisplayname(); + final avatarUrl = room.avatar; + final isDm = room.isDirectChat; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + margin: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: widget.selected || _hovered ? pt.bgHover : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + children: [ + SizedBox(width: 32, height: 32, + child: avatarUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(isDm ? 16 : pt.rSm), + child: MxcImage(mxcUri: avatarUrl.toString(), client: room.client, width: 32, height: 32), + ) + : Container( + decoration: BoxDecoration( + color: _colorForName(name), + borderRadius: BorderRadius.circular(isDm ? 16 : pt.rSm), + ), + child: Center(child: Text(name.isEmpty ? '?' : name[0].toUpperCase(), + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14))), + ), + ), + const SizedBox(width: 10), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(isDm ? Icons.person_rounded : Icons.tag_rounded, size: 11, color: pt.fgDim), + const SizedBox(width: 3), + Expanded(child: Text(name, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis)), + ]), + if (room.lastEvent != null) + Text(room.lastEvent!.body, style: TextStyle(color: pt.fgMuted, fontSize: 12), overflow: TextOverflow.ellipsis, maxLines: 1), + ], + )), + if (room.notificationCount > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: pt.accent, borderRadius: BorderRadius.circular(999)), + child: Text('${room.notificationCount}', style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600)), + ), + ], + ), + ), + ), + ); + } +} + +// ─── Message result item ────────────────────────────────────────────────────── + +class _MessageItem extends StatefulWidget { + final Event event; + final Room? room; + final bool selected; + final PyramidTheme pt; + final VoidCallback onTap; + const _MessageItem({required this.event, required this.room, required this.selected, required this.pt, required this.onTap}); + @override + State<_MessageItem> createState() => _MessageItemState(); +} + +class _MessageItemState extends State<_MessageItem> { + bool _hovered = false; + + String _senderName() { + final event = widget.event; + final room = widget.room; + if (room != null) { + final member = room.unsafeGetUserFromMemoryOrFallback(event.senderId); + return member.displayName ?? event.senderId.split(':').first.replaceFirst('@', ''); + } + return event.senderId.split(':').first.replaceFirst('@', ''); + } + + String _timeLabel() { + final ts = widget.event.originServerTs; + final now = DateTime.now(); + final diff = now.difference(ts); + if (diff.inDays == 0) return '${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}'; + if (diff.inDays < 7) return _weekday(ts.weekday); + return '${ts.day}.${ts.month}.${ts.year}'; + } + + String _weekday(int d) => ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'][d - 1]; + + String _msgBody() { + final content = widget.event.content; + final msgtype = content.tryGet('msgtype') ?? ''; + if (msgtype == 'm.image') return '📷 Bild'; + if (msgtype == 'm.video') return '🎬 Video'; + if (msgtype == 'm.audio') return '🎵 Audio'; + if (msgtype == 'm.file') return '📎 ${content.tryGet('body') ?? 'Datei'}'; + return content.tryGet('body') ?? ''; + } + + IconData _roomIcon() { + final room = widget.room; + if (room == null) return Icons.tag_rounded; + return room.isDirectChat ? Icons.person_rounded : Icons.tag_rounded; + } + + bool get _isVisualMedia { + final t = widget.event.content.tryGet('msgtype') ?? ''; + return t == 'm.image' || t == 'm.video'; + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final roomName = widget.room?.getLocalizedDisplayname() ?? widget.event.roomId ?? ''; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: widget.selected || _hovered ? pt.bgHover : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_isVisualMedia) + Padding( + padding: const EdgeInsets.only(top: 1, right: 8), + child: _SearchThumb(event: widget.event, pt: pt), + ) + else ...[ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(_roomIcon(), size: 14, color: pt.fgDim), + ), + const SizedBox(width: 8), + ], + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Expanded(child: Text(roomName, style: TextStyle(color: pt.fgMuted, fontSize: 11), overflow: TextOverflow.ellipsis)), + Text(_timeLabel(), style: TextStyle(color: pt.fgDim, fontSize: 11)), + ]), + const SizedBox(height: 1), + RichText( + maxLines: 2, + overflow: TextOverflow.ellipsis, + text: TextSpan(children: [ + TextSpan(text: '${_senderName()}: ', style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600)), + TextSpan(text: _msgBody(), style: TextStyle(color: pt.fgMuted, fontSize: 13)), + ]), + ), + ], + )), + ], + ), + ), + ), + ); + } +} + +// ─── Search media thumbnail ─────────────────────────────────────────────────── +// Loads (and decrypts, if needed) a small thumbnail for image/video results. + +class _SearchThumb extends StatefulWidget { + final Event event; + final PyramidTheme pt; + const _SearchThumb({required this.event, required this.pt}); + @override + State<_SearchThumb> createState() => _SearchThumbState(); +} + +class _SearchThumbState extends State<_SearchThumb> { + static final Map _cache = {}; + Uint8List? _bytes; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final id = widget.event.eventId; + if (_cache.containsKey(id)) { + setState(() => _bytes = _cache[id]); + return; + } + try { + final file = await widget.event + .downloadAndDecryptAttachment(getThumbnail: true) + .timeout(const Duration(seconds: 10)); + _cache[id] = file.bytes; + if (mounted) setState(() => _bytes = file.bytes); + } catch (_) { + _cache[id] = null; + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + const size = 38.0; + return ClipRRect( + borderRadius: BorderRadius.circular(pt.rSm), + child: SizedBox( + width: size, + height: size, + child: _bytes != null + ? Image.memory(_bytes!, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _placeholder(pt)) + : _placeholder(pt), + ), + ); + } + + Widget _placeholder(PyramidTheme pt) => Container( + color: pt.bg3, + child: Icon( + (widget.event.content.tryGet('msgtype') ?? '') == 'm.video' + ? Icons.videocam_rounded + : Icons.image_rounded, + size: 18, + color: pt.fgDim, + ), + ); +} + +// ─── Shared chips / footer ──────────────────────────────────────────────────── + +class _Chip extends StatefulWidget { + final String label; + final IconData icon; + final bool active; + final PyramidTheme pt; + final VoidCallback onTap; + const _Chip({required this.label, required this.icon, required this.active, required this.pt, required this.onTap}); + @override + State<_Chip> createState() => _ChipState(); +} + +class _ChipState extends State<_Chip> { + bool _h = false; + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _h = true), + onExit: (_) => setState(() => _h = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: widget.active ? pt.accentSoft : _h ? pt.bgHover : Colors.transparent, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: widget.active ? pt.accent : pt.border), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(widget.icon, size: 11, color: widget.active ? pt.accent : pt.fgMuted), + const SizedBox(width: 4), + Text(widget.label, style: TextStyle( + color: widget.active ? pt.accent : pt.fgMuted, + fontSize: 12, + fontWeight: widget.active ? FontWeight.w600 : FontWeight.w400, + )), + ]), + ), + ), + ); + } +} + +class _Footer extends StatelessWidget { + final int count; + final bool loading; + final PyramidTheme pt; + const _Footer({required this.count, required this.loading, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: pt.bg2, + border: Border(top: BorderSide(color: pt.border)), + borderRadius: BorderRadius.only(bottomLeft: Radius.circular(pt.rXl), bottomRight: Radius.circular(pt.rXl)), + ), + child: Row( + children: [ + _Hint('↑↓', 'navigieren', pt), + const SizedBox(width: 16), + _Hint('↵', 'öffnen', pt), + const Spacer(), + if (loading) + SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5, color: pt.fgDim)) + else + Text( + '$count Ergebnis${count == 1 ? '' : 'se'}', + style: TextStyle(color: pt.fgDim, fontSize: 11), + ), + ], + ), + ); + } +} + +class _Hint extends StatelessWidget { + final String key_; + final String label; + final PyramidTheme pt; + const _Hint(this.key_, this.label, this.pt); + @override + Widget build(BuildContext context) => Row(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration(color: pt.bg3, borderRadius: BorderRadius.circular(4), border: Border.all(color: pt.border)), + child: Text(key_, style: TextStyle(color: pt.fgMuted, fontSize: 11, fontFamily: 'monospace')), + ), + const SizedBox(width: 4), + Text(label, style: TextStyle(color: pt.fgDim, fontSize: 11)), + ]); +} diff --git a/lib/widgets/settings_modal.dart b/lib/widgets/settings_modal.dart new file mode 100644 index 0000000..031ea3d --- /dev/null +++ b/lib/widgets/settings_modal.dart @@ -0,0 +1,5517 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart' as mcrypto; +import 'package:pyramid/core/fcm_push_service.dart'; +import 'package:pyramid/core/notification_service.dart' show openBatteryOptimizationSettings; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart' show compute; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:http/http.dart' as http; +import 'package:matrix/encryption/ssss.dart'; +import 'package:matrix/encryption/utils/bootstrap.dart'; +import 'package:matrix/encryption/utils/key_verification.dart' show KeyVerification, KeyVerificationEmoji, KeyVerificationState; +import 'package:matrix/matrix.dart'; +import 'package:pretty_qr_code/pretty_qr_code.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pointycastle/export.dart' as pc; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/e2ee_diagnostics.dart'; +import 'package:pyramid/core/livekit_call_manager.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/voip_manager.dart'; +import 'package:pyramid/core/settings_prefs.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/core/update_checker.dart'; +import 'package:pyramid/widgets/update_download_dialog.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:pyramid/widgets/banner_crop_dialog.dart'; +import 'package:pyramid/widgets/hover_region.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; +import 'package:pyramid/widgets/pyramid_loader.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// ── Megolm key export / import – top-level so compute() can use them ───────── + +const _kMegolmHeader = '-----BEGIN MEGOLM SESSION DATA-----'; +const _kMegolmFooter = '-----END MEGOLM SESSION DATA-----'; + +// PBKDF2-HMAC-SHA512. args: {pass: Uint8List, salt: Uint8List, iter: int} +Uint8List _runPbkdf2(Map args) { + final pass = args['pass'] as Uint8List; + final salt = args['salt'] as Uint8List; + final iterations = args['iter'] as int; + const blockLen = 64; // SHA-512 digest size + const dkLen = 64; + + final hmac = mcrypto.Hmac(mcrypto.sha512, pass); + final blockCount = (dkLen / blockLen).ceil(); + final dk = Uint8List(blockCount * blockLen); + + for (var i = 1; i <= blockCount; i++) { + final saltI = Uint8List(salt.length + 4); + saltI.setRange(0, salt.length, salt); + ByteData.sublistView(saltI, salt.length).setUint32(0, i); + + var u = Uint8List.fromList(hmac.convert(saltI).bytes); + final block = Uint8List.fromList(u); + + for (var j = 1; j < iterations; j++) { + u = Uint8List.fromList(hmac.convert(u).bytes); + for (var k = 0; k < blockLen; k++) { block[k] ^= u[k]; } + } + dk.setRange((i - 1) * blockLen, i * blockLen, block); + } + return dk.sublist(0, dkLen); +} + +Uint8List _aesCtr(Uint8List key, Uint8List iv, Uint8List data) { + final cipher = pc.SICStreamCipher(pc.AESEngine()) + ..init(true, pc.ParametersWithIV(pc.KeyParameter(key), iv)); + final out = Uint8List(data.length); + cipher.processBytes(data, 0, data.length, out, 0); + return out; +} + +// args: {passphrase: String, sessions: List} → encrypted String +String _encryptMegolmKeys(Map args) { + final pass = args['passphrase'] as String; + final sessions = args['sessions'] as List; + + final rng = Random.secure(); + final salt = Uint8List.fromList(List.generate(16, (_) => rng.nextInt(256))); + // IV: 8 random bytes as nonce, last 8 bytes 0x00 (counter starts at 0) + final iv = Uint8List(16); + for (var i = 0; i < 8; i++) { iv[i] = rng.nextInt(256); } + + const iterations = 100000; + final dk = _runPbkdf2({ + 'pass': Uint8List.fromList(utf8.encode(pass)), + 'salt': salt, + 'iter': iterations, + }); + final aesKey = dk.sublist(0, 32); + final hmacKey = dk.sublist(32, 64); + + final plaintext = Uint8List.fromList(utf8.encode(jsonEncode(sessions))); + final encrypted = _aesCtr(aesKey, iv, plaintext); + + final iterBe = Uint8List(4)..buffer.asByteData().setUint32(0, iterations); + + // header bytes: 0x01 | salt(16) | iv(16) | iterBe(4) | encrypted(n) + final header = Uint8List(1 + 16 + 16 + 4 + encrypted.length); + var off = 0; + header[off++] = 0x01; + header.setRange(off, off += 16, salt); + header.setRange(off, off += 16, iv); + header.setRange(off, off += 4, iterBe); + header.setRange(off, off + encrypted.length, encrypted); + + final mac = Uint8List.fromList( + mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(header).bytes, + ); + + final full = Uint8List(header.length + 32); + full.setRange(0, header.length, header); + full.setRange(header.length, full.length, mac); + + return '$_kMegolmHeader\n${base64.encode(full)}\n$_kMegolmFooter'; +} + +// args: {passphrase: String, data: List} → List sessions +List _decryptMegolmKeys(Map args) { + final pass = args['passphrase'] as String; + final raw = Uint8List.fromList(args['data'] as List); + + if (raw.length < 1 + 16 + 16 + 4 + 32 || raw[0] != 0x01) { + throw Exception('Ungültiges Dateiformat (Version ${raw.isEmpty ? "?" : raw[0]}).'); + } + + final salt = raw.sublist(1, 17); + final iv = raw.sublist(17, 33); + final iterations = raw.buffer.asByteData().getUint32(33); + final encrypted = raw.sublist(37, raw.length - 32); + final storedMac = raw.sublist(raw.length - 32); + + if (iterations < 1 || iterations > 10000000) { + throw Exception('Iterationsanzahl außerhalb des erlaubten Bereichs.'); + } + + final dk = _runPbkdf2({ + 'pass': Uint8List.fromList(utf8.encode(pass)), + 'salt': Uint8List.fromList(salt), + 'iter': iterations, + }); + final aesKey = dk.sublist(0, 32); + final hmacKey = dk.sublist(32, 64); + + // Verify MAC over all bytes except the appended MAC itself + final toMac = raw.sublist(0, raw.length - 32); + final expectedMac = mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(toMac).bytes; + var macOk = true; + for (var i = 0; i < 32; i++) { + if (expectedMac[i] != storedMac[i]) { macOk = false; break; } + } + if (!macOk) throw Exception('Falsches Passwort oder beschädigte Datei.'); + + final decrypted = _aesCtr(aesKey, Uint8List.fromList(iv), Uint8List.fromList(encrypted)); + return jsonDecode(utf8.decode(decrypted)) as List; +} + +// ───────────────────────────────────────────────────────────────────────────── + +class SettingsModal extends ConsumerStatefulWidget { + final VoidCallback onClose; + const SettingsModal({super.key, required this.onClose}); + + @override + ConsumerState createState() => _SettingsModalState(); +} + +class _SettingsModalState extends ConsumerState { + // null = show the category list (mobile). On desktop it falls back to the + // first section so content is always visible. + String? _section; + + // Categories shared by the desktop sidebar and the mobile list/detail. + static const List<(String, String, IconData)> _categories = [ + ('profile', 'Mein Profil', Icons.person_outline_rounded), + ('notifications', 'Benachrichtigungen', Icons.notifications_none_rounded), + ('appearance', 'Erscheinungsbild', Icons.palette_outlined), + ('voice', 'Sprache & Video', Icons.headphones_rounded), + ('keys', 'Tastaturkürzel', Icons.keyboard_rounded), + ('privacy', 'Datenschutz', Icons.lock_outline_rounded), + ('sessions', 'Sitzungen', Icons.devices_rounded), + ('encryption', 'Verschlüsselung', Icons.shield_outlined), + ('about', 'Über Pyramid', Icons.info_outline_rounded), + ]; + + String _titleFor(String id) => + _categories.firstWhere((c) => c.$1 == id, orElse: () => (id, id, Icons.settings)).$2; + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final isDark = ref.watch(themeModeProvider); + final accentIdx = ref.watch(accentProvider); + final radius = ref.watch(radiusProvider); + final density = ref.watch(densityProvider); + final motion = ref.watch(motionProvider); + + final size = MediaQuery.sizeOf(context); + final isMobile = size.width < 700; + + return GestureDetector( + onTap: widget.onClose, + child: Container( + color: Colors.black.withAlpha(150), + child: Center( + child: GestureDetector( + onTap: () {}, + child: Container( + constraints: const BoxConstraints(maxWidth: 820, maxHeight: 660), + width: isMobile ? size.width * 0.97 : 740, + height: isMobile ? size.height * 0.92 : 560, + decoration: BoxDecoration( + color: pt.bg1, + borderRadius: BorderRadius.circular(pt.rXl), + border: Border.all(color: pt.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(130), + blurRadius: 40, + offset: const Offset(0, 12), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(pt.rXl), + child: isMobile + ? _mobileLayout(pt, isDark, accentIdx, radius, density, motion) + : _desktopLayout(pt, isDark, accentIdx, radius, density, motion), + ), + ), + ), + ), + ), + ); + } + + // ── Desktop: sidebar + content side by side ────────────────────────────── + Widget _desktopLayout(PyramidTheme pt, bool isDark, int accentIdx, + double radius, double density, double motion) { + final active = _section ?? 'appearance'; + return Row( + children: [ + Container( + width: 200, + decoration: BoxDecoration( + color: pt.bg2, + border: Border(right: BorderSide(color: pt.border)), + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + _NavSection('Account', pt), + _NavItem('profile', 'Mein Profil', Icons.person_outline_rounded, active, pt, () => setState(() => _section = 'profile')), + _NavItem('notifications', 'Benachrichtigungen', Icons.notifications_none_rounded, active, pt, () => setState(() => _section = 'notifications')), + const SizedBox(height: 14), + _NavSection('App', pt), + _NavItem('appearance', 'Erscheinungsbild', Icons.palette_outlined, active, pt, () => setState(() => _section = 'appearance')), + _NavItem('voice', 'Sprache & Video', Icons.headphones_rounded, active, pt, () => setState(() => _section = 'voice')), + _NavItem('keys', 'Tastaturkürzel', Icons.keyboard_rounded, active, pt, () => setState(() => _section = 'keys')), + const SizedBox(height: 14), + _NavSection('Sicherheit', pt), + _NavItem('privacy', 'Datenschutz', Icons.lock_outline_rounded, active, pt, () => setState(() => _section = 'privacy')), + _NavItem('sessions', 'Sitzungen', Icons.devices_rounded, active, pt, () => setState(() => _section = 'sessions')), + _NavItem('encryption', 'Verschlüsselung', Icons.shield_outlined, active, pt, () => setState(() => _section = 'encryption')), + const SizedBox(height: 14), + _NavItem('about', 'Über Pyramid', Icons.info_outline_rounded, active, pt, () => setState(() => _section = 'about')), + const Divider(height: 24, indent: 12, endIndent: 12), + _LogoutNavButton(pt: pt, isMobile: false, onClose: widget.onClose), + const SizedBox(height: 16), + ], + ), + ), + ), + Expanded( + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(28), + child: _buildContent(pt, isDark, accentIdx, radius, density, motion, active), + ), + Positioned( + top: 8, + right: 8, + child: PyrIconBtn( + icon: const Icon(Icons.close_rounded, size: 20), + tooltip: 'Schließen', + onPressed: widget.onClose, + ), + ), + ], + ), + ), + ], + ); + } + + // ── Mobile: full-width category list → detail with a back arrow ─────────── + Widget _mobileLayout(PyramidTheme pt, bool isDark, int accentIdx, + double radius, double density, double motion) { + final section = _section; + if (section == null) { + // Category list + return Column( + children: [ + _MobileHeader(title: 'Einstellungen', pt: pt, onClose: widget.onClose), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + for (final c in _categories) + _MobileCategoryTile( + label: c.$2, + icon: c.$3, + pt: pt, + onTap: () => setState(() => _section = c.$1), + ), + const Divider(height: 24, indent: 16, endIndent: 16), + _LogoutNavButton(pt: pt, isMobile: false, onClose: widget.onClose), + const SizedBox(height: 16), + ], + ), + ), + ], + ); + } + // Detail view with a back arrow + return Column( + children: [ + _MobileHeader( + title: _titleFor(section), + pt: pt, + onBack: () => setState(() => _section = null), + onClose: widget.onClose, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: _buildContent(pt, isDark, accentIdx, radius, density, motion, section), + ), + ), + ], + ); + } + + Widget _buildContent(PyramidTheme pt, bool isDark, int accentIdx, + double radius, double density, double motion, String section) { + return switch (section) { + 'appearance' => _AppearanceSection( + pt: pt, + isDark: isDark, + accentIdx: accentIdx, + radius: radius, + density: density, + motion: motion, + ref: ref), + 'profile' => _ProfileSection(pt: pt), + 'notifications' => _NotificationsSection(pt: pt), + 'voice' => _VoiceSection(pt: pt), + 'keys' => _KeysSection(pt: pt), + 'privacy' => _PrivacySection(pt: pt), + 'sessions' => _SessionsSection(pt: pt), + 'encryption' => _EncryptionSection(pt: pt, ref: ref), + 'about' => _AboutSection(pt: pt), + _ => _AppearanceSection( + pt: pt, + isDark: isDark, + accentIdx: accentIdx, + radius: radius, + density: density, + motion: motion, + ref: ref), + }; + } +} + +// ── Mobile helpers ────────────────────────────────────────────────────────── + +class _MobileHeader extends StatelessWidget { + final String title; + final PyramidTheme pt; + final VoidCallback? onBack; + final VoidCallback onClose; + const _MobileHeader({required this.title, required this.pt, this.onBack, required this.onClose}); + + @override + Widget build(BuildContext context) { + return Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: pt.bg2, + border: Border(bottom: BorderSide(color: pt.border)), + ), + child: Row( + children: [ + if (onBack != null) + IconButton( + onPressed: onBack, + icon: Icon(Icons.arrow_back_rounded, size: 20, color: pt.fg), + tooltip: 'Zurück', + ) + else + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + onPressed: onClose, + icon: Icon(Icons.close_rounded, size: 20, color: pt.fgMuted), + tooltip: 'Schließen', + ), + ], + ), + ); + } +} + +class _MobileCategoryTile extends StatelessWidget { + final String label; + final IconData icon; + final PyramidTheme pt; + final VoidCallback onTap; + const _MobileCategoryTile({required this.label, required this.icon, required this.pt, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), + child: Row( + children: [ + Icon(icon, size: 21, color: pt.fgMuted), + const SizedBox(width: 16), + Expanded( + child: Text(label, style: TextStyle(color: pt.fg, fontSize: 15)), + ), + Icon(Icons.chevron_right_rounded, size: 20, color: pt.fgDim), + ], + ), + ), + ); + } +} + +// ── Nav helpers ─────────────────────────────────────────────────────────────── + +class _NavSection extends StatelessWidget { + final String title; + final PyramidTheme pt; + const _NavSection(this.title, this.pt); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), + child: Text( + title.toUpperCase(), + style: TextStyle( + color: pt.fgDim, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.04 * 11, + ), + ), + ); + } +} + +class _NavItem extends StatefulWidget { + final String id; + final String label; + final IconData icon; + final String active; + final PyramidTheme pt; + final VoidCallback onTap; + + const _NavItem(this.id, this.label, this.icon, this.active, this.pt, this.onTap); + + @override + State<_NavItem> createState() => _NavItemState(); +} + +class _NavItemState extends State<_NavItem> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final isActive = widget.id == widget.active; + final pt = widget.pt; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.fromLTRB(8, 1, 8, 1), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: isActive + ? pt.accentSoft + : _hovered + ? pt.bgHover + : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + mainAxisAlignment: widget.label.isEmpty + ? MainAxisAlignment.center + : MainAxisAlignment.start, + children: [ + Icon( + widget.icon, + size: 14, + color: isActive ? pt.accent : _hovered ? pt.fg : pt.fgMuted, + ), + if (widget.label.isNotEmpty) ...[ + const SizedBox(width: 8), + Text( + widget.label, + style: TextStyle( + color: isActive ? pt.fg : _hovered ? pt.fg : pt.fgMuted, + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _LogoutNavButton extends ConsumerStatefulWidget { + final PyramidTheme pt; + final bool isMobile; + final VoidCallback onClose; + const _LogoutNavButton({required this.pt, required this.isMobile, required this.onClose}); + + @override + ConsumerState<_LogoutNavButton> createState() => _LogoutNavButtonState(); +} + +class _LogoutNavButtonState extends ConsumerState<_LogoutNavButton> { + bool _hovered = false; + bool _loading = false; + + Future _logout() 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('Abmelden?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text('Du wirst von diesem Gerät abgemeldet.', + 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('Abmelden'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _loading = true); + try { + widget.onClose(); + final client = await ref.read(matrixClientProvider.future); + await client.logout(); + } catch (_) {} + } + + @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: _loading ? null : _logout, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.fromLTRB(8, 1, 8, 1), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: _hovered ? pt.danger.withAlpha(30) : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + mainAxisAlignment: widget.isMobile ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + _loading + ? SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: pt.danger)) + : Icon(Icons.logout_rounded, size: 14, color: pt.danger), + if (!widget.isMobile) ...[ + const SizedBox(width: 8), + Text('Abmelden', style: TextStyle(color: pt.danger, fontSize: 13)), + ], + ], + ), + ), + ), + ); + } +} + +// ── Shared UI primitives ────────────────────────────────────────────────────── + +class _SectionTitle extends StatelessWidget { + final String title; + final String subtitle; + final PyramidTheme pt; + const _SectionTitle({required this.title, required this.subtitle, required this.pt}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Text(subtitle, style: TextStyle(color: pt.fgMuted, fontSize: 13)), + const SizedBox(height: 24), + ], + ); + } +} + +class _SettingsGroup extends StatelessWidget { + final String title; + final PyramidTheme pt; + final List children; + + const _SettingsGroup({required this.title, required this.pt, required this.children}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.toUpperCase(), + style: TextStyle( + color: pt.fgDim, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.04 * 11), + ), + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: pt.border), + ), + child: Column(children: children), + ), + ], + ); + } +} + +class _SettingsRow extends StatelessWidget { + final String title; + final String? desc; + final PyramidTheme pt; + final Widget? control; + final VoidCallback? onTap; + final bool destructive; + final bool showArrow; + + const _SettingsRow({ + required this.title, + required this.pt, + this.desc, + this.control, + this.onTap, + this.destructive = false, + this.showArrow = false, + }); + + @override + Widget build(BuildContext context) { + Widget content = Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: destructive ? pt.danger : pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + if (desc != null) ...[ + const SizedBox(height: 2), + Text(desc!, style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ], + ), + ), + ?control, + if (showArrow) + Icon(Icons.chevron_right_rounded, size: 16, color: pt.fgDim), + ], + ), + ); + + if (onTap != null) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: content, + ), + ); + } + return content; + } +} + +class _Divider extends StatelessWidget { + final PyramidTheme pt; + const _Divider({required this.pt}); + + @override + Widget build(BuildContext context) { + return Container(height: 1, color: pt.border, margin: const EdgeInsets.only(left: 14)); + } +} + +class _Toggle extends StatelessWidget { + final bool value; + final ValueChanged onChanged; + final PyramidTheme pt; + const _Toggle({required this.value, required this.onChanged, required this.pt}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onChanged(!value), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 42, + height: 24, + decoration: BoxDecoration( + color: value ? pt.accent : pt.bg3, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: value ? pt.accent : pt.borderStrong), + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: AnimatedAlign( + duration: const Duration(milliseconds: 200), + alignment: value ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black.withAlpha(60), blurRadius: 4)], + ), + ), + ), + ), + ), + ); + } +} + +class _SegControl extends StatelessWidget { + final List options; + final int selected; + final PyramidTheme pt; + final ValueChanged onSelect; + + const _SegControl( + {required this.options, required this.selected, required this.pt, required this.onSelect}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: options.asMap().entries.map((e) { + final isActive = e.key == selected; + return GestureDetector( + onTap: () => onSelect(e.key), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: isActive ? pt.accent : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm - 2), + ), + child: Text( + e.value, + style: TextStyle( + color: isActive ? pt.accentFg : pt.fgMuted, + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ); + }).toList(), + ), + ); + } +} + +class _PrimaryBtn extends StatelessWidget { + final String label; + final IconData? icon; + final VoidCallback? onPressed; + final PyramidTheme pt; + final bool loading; + final bool destructive; + + const _PrimaryBtn({ + required this.label, + required this.pt, + this.icon, + this.onPressed, + this.loading = false, + this.destructive = false, + }); + + @override + Widget build(BuildContext context) { + final bg = destructive ? pt.danger : pt.accent; + final fg = destructive ? Colors.white : pt.accentFg; + return ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: bg, + foregroundColor: fg, + disabledBackgroundColor: bg.withAlpha(100), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, + ), + onPressed: loading ? null : onPressed, + icon: loading + ? SizedBox( + width: 14, height: 14, child: CircularProgressIndicator(color: fg, strokeWidth: 2)) + : (icon != null ? Icon(icon, size: 16) : const SizedBox.shrink()), + label: Text(label), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// PROFILE +// ───────────────────────────────────────────────────────────────────────────── + +class _ProfileSection extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _ProfileSection({required this.pt}); + + @override + ConsumerState<_ProfileSection> createState() => _ProfileSectionState(); +} + +class _ProfileSectionState extends ConsumerState<_ProfileSection> { + late TextEditingController _nameCtrl; + late TextEditingController _statusCtrl; + bool _saving = false; + bool _saved = false; + String? _error; + Uint8List? _pendingAvatar; + Uri? _avatarUri; + Uint8List? _pendingBanner; + String? _bannerMxcUri; + Uint8List? _pendingServerBanner; + bool _serverBannerSaving = false; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController(); + _statusCtrl = TextEditingController(); + _loadProfile(); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _statusCtrl.dispose(); + super.dispose(); + } + + Future _loadProfile() async { + final client = await ref.read(matrixClientProvider.future); + if (!mounted) return; + + // 1) SOFORT aus dem lokalen Cache füllen — kein Warten auf den Server. + _nameCtrl.text = client.userID ?? ''; + for (final room in client.rooms) { + final u = room.unsafeGetUserFromMemoryOrFallback(client.userID!); + if (u.displayName != null || u.avatarUrl != null) { + if (u.displayName != null) _nameCtrl.text = u.displayName!; + _avatarUri = u.avatarUrl; + break; + } + } + try { + final bannerData = client.accountData['com.pyramid.profile.banner']; + _bannerMxcUri = bannerData?.content['url'] as String?; + } catch (_) {} + setState(() {}); + + // 2) Server-Daten PARALLEL nachladen, UI aktualisiert sich pro Antwort. + client + .getProfileFromUserId(client.userID!) + .then((profile) { + if (!mounted) return; + setState(() { + if (profile.displayName != null) _nameCtrl.text = profile.displayName!; + _avatarUri = profile.avatarUrl ?? _avatarUri; + }); + }).catchError((_) {}); + + () async { + try { + final apiUri = client.homeserver!.replace( + path: + '/_matrix/client/v3/profile/${Uri.encodeComponent(client.userID!)}', + ); + final resp = await client.httpClient.get( + apiUri, + headers: {'Authorization': 'Bearer ${client.accessToken}'}, + ); + if (resp.statusCode == 200 && mounted) { + final data = jsonDecode(resp.body) as Map; + final url = data['io.pyramid.profile.banner_url'] as String?; + if (url != null) setState(() => _bannerMxcUri = url); + } + } catch (_) {} + }(); + } + + Future _pickAvatar() async { + final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); + final file = result?.files.first; + if (file == null) return; + final bytes = file.bytes ?? await file.xFile.readAsBytes(); + setState(() => _pendingAvatar = bytes); + } + + Future _pickBanner() async { + final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); + final file = result?.files.first; + if (file == null) return; + final raw = file.bytes ?? await file.xFile.readAsBytes(); + if (!mounted) return; + final cropped = await showDialog( + context: context, + builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 3.0), + ); + if (cropped == null || !mounted) return; + setState(() => _pendingBanner = cropped); + } + + Future _pickServerBanner() async { + final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); + final file = result?.files.first; + if (file == null) return; + final raw = file.bytes ?? await file.xFile.readAsBytes(); + if (!mounted) return; + final cropped = await showDialog( + context: context, + builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4), + ); + if (cropped == null || !mounted) return; + setState(() => _serverBannerSaving = true); + try { + final client = await ref.read(matrixClientProvider.future); + final uri = await client.uploadContent(cropped, filename: 'server_banner.png', contentType: 'image/png'); + await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), uri.toString()); + if (mounted) setState(() { _pendingServerBanner = cropped; _serverBannerSaving = false; }); + } catch (_) { + if (mounted) setState(() => _serverBannerSaving = false); + } + } + + Future _removeServerBanner() async { + final client = ref.read(matrixClientProvider).valueOrNull; + if (client == null) return; + await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), null); + if (mounted) setState(() => _pendingServerBanner = null); + } + + Future _save() async { + setState(() { _saving = true; _error = null; _saved = false; }); + try { + final client = await ref.read(matrixClientProvider.future); + final name = _nameCtrl.text.trim(); + if (name.isNotEmpty) { + await client.setProfileField( + client.userID!, + 'displayname', + {'displayname': name}, + ); + } + if (_pendingAvatar != null) { + await client.setAvatar(MatrixFile( + bytes: _pendingAvatar!, + name: 'avatar.jpg', + mimeType: 'image/jpeg', + )); + setState(() => _pendingAvatar = null); + } + if (_pendingBanner != null) { + final uri = await client.uploadContent( + _pendingBanner!, filename: 'banner.png', contentType: 'image/png'); + final uriStr = uri.toString(); + // Store as a profile custom field so other users can see it + await client.setProfileField( + client.userID!, + 'io.pyramid.profile.banner_url', + {'io.pyramid.profile.banner_url': uriStr}, + ); + // Also keep in account data as legacy fallback + await client.setAccountData(client.userID!, 'com.pyramid.profile.banner', + {'url': uriStr}); + setState(() { _bannerMxcUri = uriStr; _pendingBanner = null; }); + } + final status = _statusCtrl.text.trim(); + await client.setPresence( + client.userID!, + PresenceType.online, + statusMsg: status.isEmpty ? null : status, + ); + if (mounted) setState(() { _saving = false; _saved = true; }); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _saved = false); + } catch (e) { + if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; }); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final clientAsync = ref.watch(matrixClientProvider); + final client = clientAsync.valueOrNull; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Mein Profil', + subtitle: 'Wie du in Pyramid erscheinst.', + pt: pt), + // Banner + GestureDetector( + onTap: _pickBanner, + child: Stack( + children: [ + Container( + height: 100, + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + clipBehavior: Clip.hardEdge, + child: _pendingBanner != null + ? Image.memory(_pendingBanner!, fit: BoxFit.cover, width: double.infinity) + : (client != null && _bannerMxcUri != null + ? MxcImage( + mxcUri: _bannerMxcUri!, + client: client, + fit: BoxFit.cover, + width: double.infinity, + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_photo_alternate_outlined, size: 28, color: pt.fgDim), + const SizedBox(height: 4), + Text('Profilbanner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + )), + ), + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: pt.bg0.withAlpha(200), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: pt.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted), + const SizedBox(width: 4), + Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + // Avatar row + Row( + children: [ + GestureDetector( + onTap: _pickAvatar, + child: Stack( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: pt.border, width: 2), + ), + child: ClipOval( + child: _pendingAvatar != null + ? Image.memory(_pendingAvatar!, fit: BoxFit.cover) + : (client != null && _avatarUri != null + ? MxcAvatar( + mxcUri: _avatarUri, + client: client, + size: 72, + placeholder: (_) => _AvatarFallback( + name: _nameCtrl.text, size: 72, pt: pt), + ) + : _AvatarFallback( + name: _nameCtrl.text, size: 72, pt: pt)), + ), + ), + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: pt.accent, + shape: BoxShape.circle, + border: Border.all(color: pt.bg1, width: 2), + ), + child: Icon(Icons.edit_rounded, size: 12, color: pt.accentFg), + ), + ), + ], + ), + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _nameCtrl.text.isNotEmpty ? _nameCtrl.text : (client?.userID ?? ''), + style: TextStyle( + color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + SelectableText( + client?.userID ?? '', + style: TextStyle(color: pt.fgDim, fontSize: 12), + ), + const SizedBox(height: 6), + if (_pendingAvatar != null) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: pt.accent.withAlpha(40), + borderRadius: BorderRadius.circular(6), + ), + child: Text('Neues Bild ausgewählt', + style: TextStyle(color: pt.accent, fontSize: 11)), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 24), + _SettingsGroup(title: 'Name & Status', pt: pt, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 4), + child: _PrefTextField( + controller: _nameCtrl, + label: 'Anzeigename', + pt: pt, + ), + ), + const SizedBox(height: 2), + Padding( + padding: const EdgeInsets.fromLTRB(14, 4, 14, 14), + child: _PrefTextField( + controller: _statusCtrl, + label: 'Statusnachricht (optional)', + pt: pt, + maxLength: 120, + ), + ), + ]), + const SizedBox(height: 20), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _StatusCard( + pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!), + ), + if (_saved) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _StatusCard( + pt: pt, + color: PyramidColors.success, + icon: Icons.check_circle_outline_rounded, + text: 'Profil gespeichert.'), + ), + _PrimaryBtn( + label: _saving ? 'Speichern…' : 'Speichern', + icon: Icons.save_outlined, + pt: pt, + loading: _saving, + onPressed: _save, + ), + const SizedBox(height: 24), + + // ── Server-Banner ───────────────────────────────────────────────── + _SectionTitle( + title: 'Server-Darstellung', + subtitle: 'Banner für ${client?.homeserver?.host ?? 'diesen Server'}', + pt: pt, + ), + Consumer(builder: (context, ref, _) { + final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {}; + final homeserver = client?.homeserver.toString(); + final existingMxc = homeserver != null ? serverBanners[homeserver] : null; + final hasBanner = _pendingServerBanner != null || existingMxc != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: _serverBannerSaving ? null : _pickServerBanner, + child: Container( + height: 90, + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + clipBehavior: Clip.hardEdge, + child: Stack( + fit: StackFit.expand, + children: [ + if (_pendingServerBanner != null) + Image.memory(_pendingServerBanner!, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink()) + else if (existingMxc != null && client != null) + MxcImage(mxcUri: existingMxc, client: client, fit: BoxFit.cover) + else + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_photo_alternate_outlined, size: 24, color: pt.fgDim), + const SizedBox(height: 4), + Text('Server-Banner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + if (_serverBannerSaving) + Container( + color: Colors.black38, + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + if (hasBanner) + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: pt.bg0.withAlpha(200), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: pt.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted), + const SizedBox(width: 4), + Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)), + ], + ), + ), + ), + ], + ), + ), + ), + if (hasBanner) ...[ + const SizedBox(height: 8), + GestureDetector( + onTap: _removeServerBanner, + child: Text( + 'Banner entfernen', + style: TextStyle(color: pt.danger, fontSize: 12), + ), + ), + ], + ], + ); + }), + + const SizedBox(height: 24), + _SettingsGroup(title: 'Konto', pt: pt, children: [ + _SettingsRow( + title: 'Passwort ändern', + desc: 'Aktuelles Passwort zum Ändern erforderlich', + pt: pt, + showArrow: true, + onTap: _changePassword, + ), + ]), + const SizedBox(height: 12), + _SettingsGroup(title: 'Gefahrenbereich', pt: pt, children: [ + _SettingsRow( + title: 'Konto löschen', + desc: 'Konto dauerhaft deaktivieren – nicht rückgängig zu machen', + pt: pt, + showArrow: true, + destructive: true, + onTap: _deleteAccount, + ), + ]), + ], + ), + ); + } + + Future _changePassword() async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _ChangePasswordDialog(pt: widget.pt), + ); + } + + Future _deleteAccount() async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _DeleteAccountDialog(pt: widget.pt), + ); + } +} + +class _AvatarFallback extends StatelessWidget { + final String name; + final double size; + final PyramidTheme pt; + const _AvatarFallback({required this.name, required this.size, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + color: pt.accent, + child: Center( + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: + TextStyle(color: pt.accentFg, fontSize: size * 0.4, fontWeight: FontWeight.w700), + ), + ), + ); + } +} + +class _PrefTextField extends StatelessWidget { + final TextEditingController controller; + final String label; + final PyramidTheme pt; + final int? maxLength; + + const _PrefTextField({ + required this.controller, + required this.label, + required this.pt, + this.maxLength, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + maxLength: maxLength, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + labelText: label, + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + counterText: '', + filled: true, + fillColor: pt.bg3, + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + 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, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// NOTIFICATIONS +// ───────────────────────────────────────────────────────────────────────────── + +class _NotificationsSection extends ConsumerWidget { + final PyramidTheme pt; + const _NotificationsSection({required this.pt}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final desktop = ref.watch(notifDesktopProvider); + final sound = ref.watch(notifSoundProvider); + final preview = ref.watch(notifPreviewProvider); + final dmMentionOnly = ref.watch(notifMentionOnlyDmProvider); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Benachrichtigungen', + subtitle: 'Bestimme, wann und wie du benachrichtigt wirst.', + pt: pt), + _SettingsGroup(title: 'Desktop', pt: pt, children: [ + _SettingsRow( + title: 'Desktop-Benachrichtigungen', + desc: 'Zeige Systembenachrichtigungen bei neuen Nachrichten', + pt: pt, + control: _Toggle( + value: desktop, + pt: pt, + onChanged: (v) => ref.read(notifDesktopProvider.notifier).set(v), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Benachrichtigungston', + desc: 'Sound abspielen wenn eine Nachricht eingeht', + pt: pt, + control: _Toggle( + value: sound, + pt: pt, + onChanged: (v) => ref.read(notifSoundProvider.notifier).set(v), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Nachrichtenvorschau', + desc: 'Nachrichteninhalt in Benachrichtigung anzeigen', + pt: pt, + control: _Toggle( + value: preview, + pt: pt, + onChanged: (v) => ref.read(notifPreviewProvider.notifier).set(v), + ), + ), + ]), + const SizedBox(height: 20), + _SettingsGroup(title: 'Filter', pt: pt, children: [ + _SettingsRow( + title: 'Nur Erwähnungen (DMs)', + desc: 'Benachrichtigung nur bei direkten @Erwähnungen', + pt: pt, + control: _Toggle( + value: dmMentionOnly, + pt: pt, + onChanged: (v) => ref.read(notifMentionOnlyDmProvider.notifier).set(v), + ), + ), + ]), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: pt.border), + ), + child: Row( + children: [ + Icon(Icons.info_outline_rounded, size: 16, color: pt.fgDim), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Push-Benachrichtigungen für Android/iOS sind in Vorbereitung.', + style: TextStyle(color: pt.fgMuted, fontSize: 12), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// APPEARANCE +// ───────────────────────────────────────────────────────────────────────────── + +class _AppearanceSection extends StatelessWidget { + final PyramidTheme pt; + final bool isDark; + final int accentIdx; + final double radius; + final double density; + final double motion; + final WidgetRef ref; + + const _AppearanceSection({ + required this.pt, + required this.isDark, + required this.accentIdx, + required this.radius, + required this.density, + required this.motion, + required this.ref, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Erscheinungsbild', + subtitle: 'Gestalte Pyramid nach deinem Geschmack.', + pt: pt), + _SettingsGroup(title: 'Theme', pt: pt, children: [ + _SettingsRow( + title: 'Farbmodus', + desc: 'Hell oder dunkel', + pt: pt, + control: _SegControl( + options: const ['Dunkel', 'Hell'], + selected: isDark ? 0 : 1, + pt: pt, + onSelect: (i) { + final dark = i == 0; + ref.read(themeModeProvider.notifier).state = dark; + saveThemePrefs(isDark: dark); + }, + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Akzentfarbe', + desc: 'Farbe für Hervorhebungen und Buttons', + pt: pt, + control: Row( + children: accentPresets.asMap().entries.map((e) { + final i = e.key; + final preset = e.value; + final isActive = accentIdx == i; + return GestureDetector( + onTap: () { + ref.read(accentProvider.notifier).state = i; + saveThemePrefs(accentIdx: i); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 22, + height: 22, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: preset.color, + shape: BoxShape.circle, + border: isActive ? Border.all(color: Colors.white, width: 2) : null, + boxShadow: isActive + ? [BoxShadow(color: preset.color.withAlpha(100), blurRadius: 8)] + : null, + ), + ), + ); + }).toList(), + ), + ), + ]), + const SizedBox(height: 20), + _SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [ + _SettingsRow( + title: 'Eckenrundung', + desc: '${radius.round()} px', + pt: pt, + control: SizedBox( + width: 150, + child: SliderTheme( + data: SliderThemeData( + activeTrackColor: pt.accent, + thumbColor: pt.accent, + inactiveTrackColor: pt.border, + trackHeight: 3, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + ), + child: Slider( + value: radius, + min: 4, + max: 20, + onChanged: (v) { + ref.read(radiusProvider.notifier).state = v; + saveThemePrefs(radius: v); + }, + ), + ), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Animationen', + desc: '${(motion * 100).round()}%', + pt: pt, + control: SizedBox( + width: 150, + child: SliderTheme( + data: SliderThemeData( + activeTrackColor: pt.accent, + thumbColor: pt.accent, + inactiveTrackColor: pt.border, + trackHeight: 3, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + ), + child: Slider( + value: motion, + min: 0, + max: 1, + onChanged: (v) { + ref.read(motionProvider.notifier).state = v; + saveThemePrefs(motion: v); + }, + ), + ), + ), + ), + ]), + const SizedBox(height: 20), + _SettingsGroup(title: 'Vorschau', pt: pt, children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Column( + children: [ + Container( + height: 100, + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + child: const Center(child: PyramidLoader(size: 70, useLottie: true)), + ), + const SizedBox(height: 6), + Text('Lottie', style: TextStyle(color: pt.fgMuted, fontSize: 11)), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + children: [ + Container( + height: 100, + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + child: const Center(child: PyramidLoader(size: 70, useLottie: false)), + ), + const SizedBox(height: 6), + Text('SVG', style: TextStyle(color: pt.fgMuted, fontSize: 11)), + ], + ), + ), + ], + ), + ), + ]), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// VOICE & VIDEO +// ───────────────────────────────────────────────────────────────────────────── + +class _VoiceSection extends StatefulWidget { + final PyramidTheme pt; + const _VoiceSection({required this.pt}); + + @override + State<_VoiceSection> createState() => _VoiceSectionState(); +} + +class _VoiceSectionState extends State<_VoiceSection> { + List _audioInputs = []; + List _audioOutputs = []; + List _videoInputs = []; + String? _selectedMic; + String? _selectedSpeaker; + String? _selectedCamera; + double _outputVolume = 1.0; + bool _loading = true; + String? _loadError; + + static const _kMic = 'voice_mic_device'; + static const _kSpeaker = 'voice_speaker_device'; + static const _kCamera = 'voice_camera_device'; + static const _kOutputVolume = 'voice_output_volume'; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + final prefs = await SharedPreferences.getInstance(); + List devices = []; + String? loadError; + try { + devices = await navigator.mediaDevices.enumerateDevices(); + } catch (_) { + devices = []; + } + // Windows (und teils Android) liefert eine LEERE Liste ohne Fehler, + // solange in diesem Prozess noch nie ein Media-Stream geöffnet wurde. + // Deshalb nicht nur bei Exception, sondern auch bei leerer Liste einmal + // kurz getUserMedia anfordern und erneut enumerieren — und zwar SOLANGE + // der Stream noch offen ist (nach stop() melden manche Systeme wieder 0). + final hasAudioInput = + devices.any((d) => d.kind?.toLowerCase() == 'audioinput'); + if (devices.isEmpty || !hasAudioInput) { + try { + final stream = await navigator.mediaDevices + .getUserMedia({'audio': true, 'video': false}); + try { + devices = await navigator.mediaDevices.enumerateDevices(); + } finally { + for (final t in stream.getTracks()) { + t.stop(); + } + await stream.dispose(); + } + if (devices.isEmpty) { + // Bekanntes libwebrtc-Problem auf manchen Windows-Systemen: das + // Audio-Device-Module meldet 0 Geräte, obwohl Aufnahme/Wiedergabe + // über das Standardgerät einwandfrei funktionieren. + loadError = + 'Die Audiotreiber melden keine Geräteliste an WebRTC ' + '(bekanntes libwebrtc-Problem auf manchen Windows-Systemen). ' + 'Anrufe funktionieren trotzdem — es wird das Windows-Standardgerät verwendet.'; + } + } catch (e2) { + loadError = 'getUserMedia: ${e2.toString().split('\n').first}'; + } + } + if (!mounted) return; + setState(() { + _audioInputs = devices.where((d) => d.kind?.toLowerCase() == 'audioinput').toList(); + _audioOutputs = devices.where((d) => d.kind?.toLowerCase() == 'audiooutput').toList(); + _videoInputs = devices.where((d) => d.kind?.toLowerCase() == 'videoinput').toList(); + _selectedMic = prefs.getString(_kMic); + _selectedSpeaker = prefs.getString(_kSpeaker); + _selectedCamera = prefs.getString(_kCamera); + _outputVolume = prefs.getDouble(_kOutputVolume) ?? 1.0; + _loadError = loadError; + _loading = false; + }); + } + + Future _setOutputVolume(double v) async { + setState(() => _outputVolume = v); + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_kOutputVolume, v); + // Live auf laufende Calls anwenden. + LiveKitCallManager.instance.setOutputVolume(v).catchError((_) {}); + try { + PyramidVoipManager.instance.applyOutputVolume(v).catchError((_) {}); + } catch (_) {} // Instanz existiert evtl. noch nicht + } + + Future _save(String key, String? value) async { + final prefs = await SharedPreferences.getInstance(); + if (value == null || value.isEmpty) { + await prefs.remove(key); + } else { + await prefs.setString(key, value); + } + } + + Widget _deviceDropdown({ + required List devices, + required String? selected, + required String placeholder, + required IconData icon, + required ValueChanged onChanged, + }) { + final pt = widget.pt; + if (devices.isEmpty) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: pt.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: pt.fgDim), + const SizedBox(width: 6), + Text('Kein Gerät', style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ); + } + + final validSelected = devices.any((d) => d.deviceId == selected) ? selected : null; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: pt.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: validSelected, + hint: Row(children: [ + Icon(icon, size: 13, color: pt.fgDim), + const SizedBox(width: 6), + Text(placeholder, style: TextStyle(color: pt.fgDim, fontSize: 12)), + ]), + dropdownColor: pt.bg2, + iconEnabledColor: pt.fgDim, + isDense: true, + style: TextStyle(color: pt.fg, fontSize: 12), + onChanged: onChanged, + items: devices.map((d) { + final label = d.label.isNotEmpty ? d.label : 'Gerät ${d.deviceId.substring(0, 6)}'; + return DropdownMenuItem( + value: d.deviceId, + child: Text(label, overflow: TextOverflow.ellipsis), + ); + }).toList(), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Sprache & Video', + subtitle: 'Mikrofon, Kamera und Anrufeinstellungen.', + pt: pt), + if (_loading) + const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive())) + else ...[ + if (_loadError != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: pt.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.warning_amber_rounded, size: 16, color: pt.away), + const SizedBox(width: 8), + Text('Geräteliste nicht verfügbar', + style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), + ]), + const SizedBox(height: 6), + Text(_loadError!, style: TextStyle(color: pt.fgDim, fontSize: 11)), + const SizedBox(height: 6), + Text( + 'Mikrofon und Lautsprecher legst du in diesem Fall über die ' + 'Windows-Soundeinstellungen (Standardgerät) fest. ' + 'Falls dort alles stimmt: Windows-Einstellungen → Datenschutz → Mikrofon prüfen.', + style: TextStyle(color: pt.fgMuted, fontSize: 11), + ), + ], + ), + ), + ), + _SettingsGroup(title: 'Geräte', pt: pt, children: [ + _SettingsRow( + title: 'Mikrofon', + desc: _audioInputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioInputs.length} Gerät(e) gefunden', + pt: pt, + control: _deviceDropdown( + devices: _audioInputs, + selected: _selectedMic, + placeholder: 'Standard', + icon: Icons.mic_rounded, + onChanged: (v) { + setState(() => _selectedMic = v); + _save(_kMic, v); + }, + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Lautsprecher', + desc: _audioOutputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioOutputs.length} Gerät(e) gefunden', + pt: pt, + control: _deviceDropdown( + devices: _audioOutputs, + selected: _selectedSpeaker, + placeholder: 'Standard', + icon: Icons.volume_up_rounded, + onChanged: (v) { + setState(() => _selectedSpeaker = v); + _save(_kSpeaker, v); + }, + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Kamera', + desc: _videoInputs.isEmpty ? 'Kein Gerät gefunden' : '${_videoInputs.length} Gerät(e) gefunden', + pt: pt, + control: _deviceDropdown( + devices: _videoInputs, + selected: _selectedCamera, + placeholder: 'Standard', + icon: Icons.videocam_rounded, + onChanged: (v) { + setState(() => _selectedCamera = v); + _save(_kCamera, v); + }, + ), + ), + ]), + const SizedBox(height: 16), + _SettingsGroup(title: 'Pegel', pt: pt, children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.volume_up_rounded, size: 15, color: pt.fgMuted), + const SizedBox(width: 8), + Text('Ausgabelautstärke', + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500)), + const Spacer(), + Text('${(_outputVolume * 100).round()} %', + style: TextStyle( + color: pt.fgMuted, + fontSize: 12, + fontFamily: 'monospace')), + ], + ), + SliderTheme( + data: SliderThemeData( + activeTrackColor: pt.accent, + inactiveTrackColor: pt.bg3, + thumbColor: pt.accent, + overlayColor: pt.accent.withAlpha(30), + trackHeight: 3, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 7), + ), + child: Slider( + value: _outputVolume.clamp(0.0, 1.5), + min: 0.0, + max: 1.5, + divisions: 30, + onChanged: _setOutputVolume, + ), + ), + Text( + 'Lautstärke der anderen Teilnehmer in Calls. ' + 'Wirkt sofort auf laufende Anrufe.', + style: TextStyle(color: pt.fgDim, fontSize: 11), + ), + ], + ), + ), + ]), + const SizedBox(height: 16), + _TurnUsageCard(pt: pt), + const SizedBox(height: 12), + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: pt.fgMuted, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onPressed: () { setState(() { _loading = true; _loadError = null; }); _init(); }, + icon: const Icon(Icons.refresh_rounded, size: 14), + label: const Text('Geräte neu laden', style: TextStyle(fontSize: 12)), + ), + ], + ], + ), + ); + } +} + +// ── TURN-Verbrauch (Cloudflare Free Tier, vom Pi überwacht) ────────────────── + +/// Zeigt den TURN-Relay-Verbrauch des laufenden Monats an. Die Daten kommen +/// aus der turn-status.json, die der Guard-Cron auf dem Pi stündlich schreibt. +/// Solange der Endpoint nicht existiert (Analytics-Token fehlt), bleibt die +/// Karte unsichtbar. +class _TurnUsageCard extends StatefulWidget { + final PyramidTheme pt; + const _TurnUsageCard({required this.pt}); + + @override + State<_TurnUsageCard> createState() => _TurnUsageCardState(); +} + +class _TurnUsageCardState extends State<_TurnUsageCard> { + Map? _status; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final resp = await http + .get(Uri.parse( + 'https://steggi-matrix.work/.well-known/pyramid/turn-status.json')) + .timeout(const Duration(seconds: 5)); + if (resp.statusCode == 200 && mounted) { + setState( + () => _status = jsonDecode(resp.body) as Map); + } + } catch (_) { + // Endpoint (noch) nicht vorhanden — Karte einfach nicht anzeigen. + } + } + + @override + Widget build(BuildContext context) { + final s = _status; + if (s == null) return const SizedBox.shrink(); + final pt = widget.pt; + final used = (s['egress_gb'] as num?)?.toDouble() ?? 0; + final limit = (s['limit_gb'] as num?)?.toDouble() ?? 990; + final pct = limit > 0 ? (used / limit).clamp(0.0, 1.0) : 0.0; + final active = s['turn_active'] != false; + final barColor = !active + ? pt.danger + : pct > 0.8 + ? pt.away + : pt.accent; + + return _SettingsGroup(title: 'Call-Relay (TURN)', pt: pt, children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(active ? Icons.cloud_done_rounded : Icons.cloud_off_rounded, + size: 15, color: active ? pt.online : pt.danger), + const SizedBox(width: 8), + Text( + active + ? 'Relay aktiv' + : 'Relay deaktiviert (Limit erreicht)', + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500)), + const Spacer(), + Text( + '${used.toStringAsFixed(1)} / ${limit.toStringAsFixed(0)} GB', + style: TextStyle( + color: pt.fgMuted, + fontSize: 12, + fontFamily: 'monospace')), + ]), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(99), + child: LinearProgressIndicator( + value: pct, + minHeight: 5, + backgroundColor: pt.bg3, + color: barColor, + ), + ), + const SizedBox(height: 6), + Text( + active + ? 'Cloudflare-TURN-Traffic diesen Monat (Free Tier: ${limit.toStringAsFixed(0)} GB). ' + 'Bei Erreichen des Limits laufen Calls bis Monatsende ohne Relay weiter.' + : 'Monatslimit erreicht — Calls nutzen bis Monatsende nur direkte Verbindungen (STUN).', + style: TextStyle(color: pt.fgDim, fontSize: 11), + ), + ], + ), + ), + ]); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// KEYBINDS +// ───────────────────────────────────────────────────────────────────────────── + +class _KeysSection extends StatelessWidget { + final PyramidTheme pt; + const _KeysSection({required this.pt}); + + @override + Widget build(BuildContext context) { + final keys = [ + ('Schnellsuche', 'Strg+K'), + ('Raum als gelesen markieren', 'Shift+Esc'), + ('Zu ungelesen springen', 'Strg+J'), + ('Linke Leiste ein-/ausblenden', 'Strg+B'), + ('Mitglieder ein-/ausblenden', 'Strg+Shift+U'), + ('Nachrichten durchsuchen', 'Strg+F'), + ('Antworten', 'R'), + ('Auswahl aufheben', 'Esc'), + ]; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Tastaturkürzel', + subtitle: 'Schnelle Aktionen per Tastatur.', + pt: pt), + _SettingsGroup( + title: 'Navigieren', + pt: pt, + children: keys.asMap().entries.map((entry) { + final (action, binding) = entry.value; + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + Expanded( + child: Text(action, + style: TextStyle(color: pt.fg, fontSize: 13))), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: pt.border), + ), + child: Text(binding, + style: TextStyle( + color: pt.fgMuted, + fontSize: 12, + fontFamily: 'monospace')), + ), + ], + ), + ), + if (entry.key < keys.length - 1) _Divider(pt: pt), + ], + ); + }).toList(), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// PRIVACY +// ───────────────────────────────────────────────────────────────────────────── + +class _PrivacySection extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _PrivacySection({required this.pt}); + + @override + ConsumerState<_PrivacySection> createState() => _PrivacySectionState(); +} + +class _PrivacySectionState extends ConsumerState<_PrivacySection> { + List _ignoredUsers = []; + final Set _unblocking = {}; + + @override + void initState() { + super.initState(); + _loadIgnoredUsers(); + } + + Future _loadIgnoredUsers() async { + final client = await ref.read(matrixClientProvider.future); + if (mounted) setState(() => _ignoredUsers = List.from(client.ignoredUsers)); + } + + Future _unblockUser(String userId) async { + setState(() => _unblocking.add(userId)); + try { + final client = await ref.read(matrixClientProvider.future); + await client.unignoreUser(userId); + await _loadIgnoredUsers(); + } catch (e) { + // ignore — list will be stale but not crash + } finally { + if (mounted) setState(() => _unblocking.remove(userId)); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final presence = ref.watch(privacyPresenceProvider); + final readReceipts = ref.watch(privacyReadReceiptsProvider); + final typing = ref.watch(privacyTypingProvider); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Datenschutz', + subtitle: 'Steuere, was andere über dich erfahren.', + pt: pt), + _SettingsGroup(title: 'Sichtbarkeit', pt: pt, children: [ + _SettingsRow( + title: 'Online-Status anzeigen', + desc: 'Anderen anzeigen, wenn du aktiv bist', + pt: pt, + control: _Toggle( + value: presence, + pt: pt, + onChanged: (v) async { + ref.read(privacyPresenceProvider.notifier).set(v); + final client = await ref.read(matrixClientProvider.future); + await client.setPresence( + client.userID!, + v ? PresenceType.online : PresenceType.offline, + ).catchError((_) {}); + }, + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Lesebestätigungen senden', + desc: 'Anderen zeigen, dass du ihre Nachrichten gelesen hast', + pt: pt, + control: _Toggle( + value: readReceipts, + pt: pt, + onChanged: (v) => ref.read(privacyReadReceiptsProvider.notifier).set(v), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Tipp-Indikator senden', + desc: '„schreibt…" Anzeige aktivieren', + pt: pt, + control: _Toggle( + value: typing, + pt: pt, + onChanged: (v) => ref.read(privacyTypingProvider.notifier).set(v), + ), + ), + ]), + const SizedBox(height: 20), + // ── Blocked users ────────────────────────────────────────────────── + _SettingsGroup(title: 'Blockierte Nutzer (${_ignoredUsers.length})', pt: pt, children: [ + if (_ignoredUsers.isEmpty) + Padding( + padding: const EdgeInsets.all(14), + child: Text('Keine blockierten Nutzer.', + style: TextStyle(color: pt.fgMuted, fontSize: 13)), + ) + else + ..._ignoredUsers.asMap().entries.map((entry) { + final i = entry.key; + final userId = entry.value; + final isUnblocking = _unblocking.contains(userId); + return Column(children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row(children: [ + Expanded( + child: Text(userId, + style: TextStyle(color: pt.fg, fontSize: 13), + overflow: TextOverflow.ellipsis), + ), + if (isUnblocking) + const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) + else + TextButton( + style: TextButton.styleFrom( + foregroundColor: pt.accent, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => _unblockUser(userId), + child: const Text('Entblocken', style: TextStyle(fontSize: 12)), + ), + ]), + ), + if (i < _ignoredUsers.length - 1) _Divider(pt: pt), + ]); + }), + ]), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.accentSoft, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: pt.accent.withAlpha(60)), + ), + child: Row( + children: [ + Icon(Icons.shield_outlined, size: 16, color: pt.accent), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Alle Nachrichten sind Ende-zu-Ende-verschlüsselt.', + style: TextStyle(color: pt.fg, fontSize: 12), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// SESSIONS +// ───────────────────────────────────────────────────────────────────────────── + +class _SessionsSection extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _SessionsSection({required this.pt}); + + @override + ConsumerState<_SessionsSection> createState() => _SessionsSectionState(); +} + +class _SessionsSectionState extends ConsumerState<_SessionsSection> { + List _devices = []; + bool _loading = true; + String? _currentDeviceId; + final Set _revoking = {}; + bool _logoutAllLoading = false; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { _loading = true; _error = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + _currentDeviceId = client.deviceID; + await client.updateUserDeviceKeys(additionalUsers: {client.userID!}); + final keys = client.userDeviceKeys[client.userID]; + if (mounted) { + setState(() { + _devices = keys?.deviceKeys.values.toList() ?? []; + _loading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _loading = false; + _error = e.toString().split('\n').first; + }); + } + } + } + + Future _revokeDevice(String deviceId) async { + setState(() => _revoking.add(deviceId)); + try { + final client = await ref.read(matrixClientProvider.future); + await client.deleteDevice(deviceId); + await _load(); + } catch (e) { + if (mounted) { + setState(() { + _revoking.remove(deviceId); + _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; + }); + } + } + } + + Future _renameCurrentDevice(BuildContext context) async { + final client = await ref.read(matrixClientProvider.future); + final currentName = _devices + .where((d) => d.deviceId == _currentDeviceId) + .map((d) => d.unsigned?['device_display_name'] as String? ?? d.deviceId ?? '') + .firstOrNull ?? ''; + + final ctrl = TextEditingController(text: currentName); + 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('Gerät umbenennen', + 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( + labelText: 'Gerätename', + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + filled: true, fillColor: pt.bg3, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + 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, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Umbenennen'), + ), + ], + ), + ); + ctrl.dispose(); + if (confirmed != true || _currentDeviceId == null) return; + try { + await client.updateDevice(_currentDeviceId!, displayName: ctrl.text.trim()); + await _load(); + } catch (e) { + if (mounted) setState(() => _error = 'Umbenennen fehlgeschlagen: ${e.toString().split('\n').first}'); + } + } + + Future _logoutAllOtherDevices(BuildContext context) async { + final pt = widget.pt; + final otherIds = _devices + .where((d) => d.deviceId != _currentDeviceId && d.deviceId != null) + .map((d) => d.deviceId!) + .toList(); + if (otherIds.isEmpty) return; + + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), + title: Text('Alle anderen Sitzungen abmelden?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text( + '${otherIds.length} Gerät${otherIds.length == 1 ? '' : 'e'} werden abgemeldet. Nicht gespeicherte Nachrichten auf diesen Geräten können verloren gehen.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + 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('Alle abmelden'), + ), + ], + ), + ); + if (confirm != true || !mounted) return; + setState(() { _logoutAllLoading = true; _error = null; }); + + final client = await ref.read(matrixClientProvider.future); + try { + await client.deleteDevices(otherIds); + } on MatrixException catch (e) { + if (e.requireAdditionalAuthentication && mounted) { + final password = await _askPassword(context, pt); + if (password == null || !mounted) { setState(() => _logoutAllLoading = false); return; } + try { + await client.deleteDevices(otherIds, auth: AuthenticationData( + type: AuthenticationTypes.password, + session: e.session, + additionalFields: { + 'identifier': {'type': 'm.id.user', 'user': client.userID}, + 'password': password, + }, + )); + } catch (e2) { + if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e2.toString().split('\n').first}'; }); + return; + } + } else { + if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; }); + return; + } + } catch (e) { + if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; }); + return; + } + await _load(); + if (mounted) setState(() => _logoutAllLoading = false); + } + + Future _askPassword(BuildContext context, PyramidTheme pt) { + final ctrl = TextEditingController(); + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), + title: Text('Passwort bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w700)), + content: TextField( + controller: ctrl, + autofocus: true, + obscureText: true, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + labelText: 'Passwort', + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + filled: true, fillColor: pt.bg3, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + 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: (_) => Navigator.pop(ctx, ctrl.text), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, null), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, ctrl.text), + child: const Text('Bestätigen'), + ), + ], + ), + ).then((v) { ctrl.dispose(); return v; }); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Sitzungen', + subtitle: 'Geräte, auf denen du angemeldet bist.', + pt: pt), + if (_loading) + const Center( + child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive())) + else if (_error != null) + _StatusCard( + pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!) + else ...[ + _SettingsGroup( + title: 'Aktive Sitzungen (${_devices.length})', + pt: pt, + children: _devices.isEmpty + ? [ + Padding( + padding: const EdgeInsets.all(16), + child: Text('Keine Sitzungen gefunden.', + style: TextStyle(color: pt.fgMuted, fontSize: 13)), + ) + ] + : _devices.asMap().entries.map((entry) { + final i = entry.key; + final device = entry.value; + final isCurrent = device.deviceId == _currentDeviceId; + final isRevoking = _revoking.contains(device.deviceId); + final isVerified = device.verified; + final displayName = device.unsigned?['device_display_name'] as String? ?? + device.deviceId ?? + 'Unbekanntes Gerät'; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isCurrent + ? pt.accentSoft + : pt.bg3, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.devices_rounded, + size: 18, + color: isCurrent ? pt.accent : pt.fgMuted, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + displayName, + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + if (isCurrent) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: pt.accentSoft, + borderRadius: BorderRadius.circular(4), + ), + child: Text('Dieses Gerät', + style: TextStyle( + color: pt.accent, + fontSize: 10, + fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _renameCurrentDevice(context), + child: Tooltip( + message: 'Gerät umbenennen', + child: Icon(Icons.edit_rounded, size: 13, color: pt.fgDim), + ), + ), + ], + if (isVerified == true && !isCurrent) + Icon(Icons.verified_rounded, + size: 14, color: PyramidColors.success), + if (isVerified == false && !isCurrent) + Icon(Icons.warning_amber_rounded, + size: 14, color: pt.away), + ], + ), + const SizedBox(height: 2), + SelectableText( + device.deviceId ?? '', + style: TextStyle( + color: pt.fgDim, + fontSize: 11, + fontFamily: 'monospace'), + ), + ], + ), + ), + if (!isCurrent) ...[ + const SizedBox(width: 8), + if (isVerified == false) + GestureDetector( + onTap: () => _verifyDevice(context, device, displayName), + child: Tooltip( + message: 'Verifizieren', + child: Icon(Icons.verified_user_outlined, + size: 16, color: pt.accent), + ), + ), + const SizedBox(width: 6), + if (isRevoking) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: pt.danger), + ) + else + GestureDetector( + onTap: () => _confirmRevoke(context, device, displayName), + child: Tooltip( + message: 'Abmelden', + child: Icon(Icons.logout_rounded, + size: 16, color: pt.fgDim), + ), + ), + ], + ], + ), + ), + if (i < _devices.length - 1) _Divider(pt: pt), + ], + ); + }).toList(), + ), + const SizedBox(height: 12), + Row( + children: [ + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: pt.fgMuted, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 14), + label: const Text('Aktualisieren', style: TextStyle(fontSize: 12)), + ), + const Spacer(), + if (_logoutAllLoading) + const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) + else if (_devices.where((d) => d.deviceId != _currentDeviceId).isNotEmpty) + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: pt.danger, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onPressed: () => _logoutAllOtherDevices(context), + icon: const Icon(Icons.logout_rounded, size: 14), + label: const Text('Alle anderen abmelden', style: TextStyle(fontSize: 12)), + ), + ], + ), + ], + ], + ), + ); + } + + Future _verifyDevice( + BuildContext context, DeviceKeys device, String displayName) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _SasVerificationDialog( + pt: widget.pt, + deviceName: displayName, + device: device, + ), + ); + await _load(); + } + + Future _confirmRevoke( + BuildContext context, DeviceKeys device, String displayName) async { + final confirm = await showDialog( + context: context, + builder: (ctx) { + final pt = widget.pt; + return AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + title: Text('Gerät abmelden?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text( + '"$displayName" wird abgemeldet und alle lokalen Schlüssel werden gelöscht.', + 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('Abmelden'), + ), + ], + ); + }, + ); + if (confirm == true && device.deviceId != null) { + await _revokeDevice(device.deviceId!); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// SAS VERIFICATION DIALOG +// ───────────────────────────────────────────────────────────────────────────── + +class _SasVerificationDialog extends ConsumerStatefulWidget { + final PyramidTheme pt; + final String deviceName; + final DeviceKeys device; + + const _SasVerificationDialog({ + required this.pt, + required this.deviceName, + required this.device, + }); + + @override + ConsumerState<_SasVerificationDialog> createState() => _SasVerificationDialogState(); +} + +class _SasVerificationDialogState extends ConsumerState<_SasVerificationDialog> { + KeyVerification? _verification; + KeyVerificationState _state = KeyVerificationState.waitingAccept; + bool _starting = true; + String? _error; + + @override + void initState() { + super.initState(); + _startVerification(); + } + + @override + void dispose() { + final v = _verification; + if (v != null && !v.isDone) { + v.cancel('m.user').catchError((_) {}); + } + super.dispose(); + } + + Future _startVerification() async { + try { + final client = await ref.read(matrixClientProvider.future); + final deviceId = widget.device.deviceId; + if (deviceId == null) throw Exception('Keine Geräte-ID'); + + final deviceKeys = client.userDeviceKeys[client.userID]?.deviceKeys[deviceId]; + if (deviceKeys == null) throw Exception('Gerät nicht gefunden'); + + final verification = await deviceKeys.startVerification(); + _verification = verification; + + verification.onUpdate = () { + if (!mounted) return; + setState(() => _state = verification.state); + }; + + if (mounted) { + setState(() { + _state = verification.state; + _starting = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString().split('\n').first; + _starting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + + return AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + title: Row( + children: [ + Icon(Icons.verified_user_outlined, size: 20, color: pt.accent), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Gerät verifizieren', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + ], + ), + content: SizedBox( + width: 380, + child: _buildContent(pt), + ), + actions: _buildActions(pt), + ); + } + + Widget _buildContent(PyramidTheme pt) { + if (_error != null) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _StatusCard( + pt: pt, + icon: Icons.error_outline_rounded, + color: pt.danger, + text: _error!), + ); + } + + if (_starting) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator.adaptive()), + ); + } + + final v = _verification; + + return switch (_state) { + KeyVerificationState.waitingAccept || KeyVerificationState.askAccept => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: 16), + Text( + 'Warte auf Bestätigung von „${widget.deviceName}"…', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 8), + Text( + 'Öffne Pyramid auf dem anderen Gerät und bestätige die Verifikationsanfrage.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgDim, fontSize: 12), + ), + ], + ), + ), + KeyVerificationState.askChoice => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Das andere Gerät hat die Anfrage akzeptiert.\nWähle eine Verifikationsmethode:', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: () => v?.continueVerification('m.sas.v1').catchError((_) {}), + icon: const Icon(Icons.tag_faces_rounded, size: 18), + label: const Text('Emoji-Verifikation'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: pt.bg3, + foregroundColor: pt.fg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: () => v?.continueVerification('m.qr_code.show.v1').catchError((_) {}), + icon: const Icon(Icons.qr_code_rounded, size: 18), + label: const Text('QR-Code anzeigen'), + ), + const SizedBox(height: 8), + ], + ), + ), + KeyVerificationState.confirmQRScan => _OutgoingQrCodeDisplay( + pt: pt, + request: v, + ), + KeyVerificationState.showQRSuccess => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _StatusCard( + pt: pt, + icon: Icons.verified_rounded, + color: PyramidColors.success, + text: 'QR-Code erfolgreich gescannt. Verifikation abgeschlossen.'), + ), + KeyVerificationState.askSas => _SasEmojiContent( + pt: pt, + verification: v!, + onConfirm: () => v.acceptSas().catchError((_) {}), + onReject: () { + v.rejectSas().catchError((_) {}); + Navigator.of(context).pop(); + }, + ), + KeyVerificationState.waitingSas => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: 16), + Text( + 'Warte auf Bestätigung des anderen Geräts…', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + ], + ), + ), + KeyVerificationState.done => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _StatusCard( + pt: pt, + icon: Icons.verified_rounded, + color: PyramidColors.success, + text: '„${widget.deviceName}" wurde erfolgreich verifiziert.'), + ), + _ => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _StatusCard( + pt: pt, + icon: Icons.error_outline_rounded, + color: pt.danger, + text: 'Verifizierung fehlgeschlagen oder abgebrochen.'), + ), + }; + } + + List _buildActions(PyramidTheme pt) { + if (_state == KeyVerificationState.done || + _state == KeyVerificationState.showQRSuccess || + _error != null || + _state == KeyVerificationState.error) { + return [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(context), + child: const Text('Schließen'), + ), + ]; + } + if (_state == KeyVerificationState.confirmQRScan) { + return [ + TextButton( + onPressed: () { + _verification?.cancel('m.user').catchError((_) {}); + Navigator.pop(context); + }, + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF22C55E), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => _verification?.acceptQRScanConfirmation().catchError((_) {}), + child: const Text('Gescannt bestätigen'), + ), + ]; + } + if (_state == KeyVerificationState.askSas || _state == KeyVerificationState.askChoice) { + return []; + } + return [ + TextButton( + onPressed: () { + _verification?.cancel('m.user').catchError((_) {}); + Navigator.pop(context); + }, + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ]; + } +} + +class _OutgoingQrCodeDisplay extends StatelessWidget { + final PyramidTheme pt; + final KeyVerification? request; + + const _OutgoingQrCodeDisplay({required this.pt, required this.request}); + + @override + Widget build(BuildContext context) { + final qrCode = request?.qrCode; + if (qrCode == null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: 12), + Text('QR-Code wird generiert…', style: TextStyle(color: pt.fgMuted, fontSize: 13)), + ], + ), + ); + } + + try { + final rawBytes = Uint8List.fromList(qrCode.qrDataRawBytes.toList()); + final qr = QrCode.fromUint8List(data: rawBytes, errorCorrectLevel: QrErrorCorrectLevel.L); + final qrImage = QrImage(qr); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Scanne diesen QR-Code mit dem anderen Gerät', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 16), + Center( + child: Container( + width: 220, + height: 220, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: PrettyQrView(qrImage: qrImage), + ), + ), + const SizedBox(height: 12), + Text( + 'Sobald das andere Gerät den Code gescannt hat, tippe auf „Gescannt bestätigen".', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgDim, fontSize: 12), + ), + const SizedBox(height: 8), + ], + ), + ); + } catch (_) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _StatusCard( + pt: pt, + icon: Icons.error_outline_rounded, + color: pt.danger, + text: 'QR-Code konnte nicht generiert werden.', + ), + ); + } + } +} + +class _SasEmojiContent extends StatelessWidget { + final PyramidTheme pt; + final KeyVerification verification; // ignore: library_private_types_in_public_api + final VoidCallback onConfirm; + final VoidCallback onReject; + + const _SasEmojiContent({ + required this.pt, + required this.verification, + required this.onConfirm, + required this.onReject, + }); + + @override + Widget build(BuildContext context) { + final emojis = verification.sasEmojis; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Vergleiche die Emojis auf beiden Geräten', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 20), + if (emojis.isNotEmpty) + Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.center, + children: emojis.map((e) => _SasEmojiChip(emoji: e, pt: pt)).toList(), + ) + else + Text('Keine Emojis verfügbar.', style: TextStyle(color: pt.fgDim, fontSize: 13)), + const SizedBox(height: 24), + Text( + 'Stimmen alle Emojis überein?', + style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: pt.danger, + side: BorderSide(color: pt.danger.withAlpha(120)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: onReject, + icon: const Icon(Icons.close_rounded, size: 16), + label: const Text('Stimmt nicht überein'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: PyramidColors.success, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: onConfirm, + icon: const Icon(Icons.check_rounded, size: 16), + label: const Text('Stimmt überein'), + ), + ), + ], + ), + const SizedBox(height: 8), + ], + ), + ); + } +} + +class _SasEmojiChip extends StatelessWidget { + final KeyVerificationEmoji emoji; + final PyramidTheme pt; + + const _SasEmojiChip({required this.emoji, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + width: 68, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: pt.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(emoji.emoji, style: const TextStyle(fontSize: 26)), + const SizedBox(height: 4), + Text( + emoji.name, + style: TextStyle(color: pt.fgMuted, fontSize: 10), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// ENCRYPTION +// ───────────────────────────────────────────────────────────────────────────── + +enum _EncState { idle, loading, needsKey, unlocking, done, error } +enum _RecoveryStep { loading, showKey, confirmKey, applying, done, error } + +class _EncryptionSection extends ConsumerStatefulWidget { + final PyramidTheme pt; + final WidgetRef ref; + const _EncryptionSection({required this.pt, required this.ref}); + + @override + ConsumerState<_EncryptionSection> createState() => _EncryptionSectionState(); +} + +class _EncryptionSectionState extends ConsumerState<_EncryptionSection> { + _EncState _state = _EncState.idle; + Bootstrap? _bootstrap; + final _keyCtrl = TextEditingController(); + String? _error; + + // Status loaded from encryption object + bool? _crossSigningEnabled; + bool? _crossSigningCached; + bool? _keyBackupEnabled; + bool _statusLoaded = false; + + // Key file export / import + bool _exportLoading = false; + bool _importLoading = false; + String? _keyFileMsg; + bool _keyFileMsgIsError = false; + bool _diagUploading = false; + String? _diagMsg; + bool _diagMsgIsError = false; + + @override + void initState() { + super.initState(); + _loadStatus(); + } + + @override + void dispose() { + _keyCtrl.dispose(); + super.dispose(); + } + + Future _loadStatus() async { + try { + final client = await ref.read(matrixClientProvider.future); + final enc = client.encryption; + if (enc == null) return; + final cached = await enc.crossSigning.isCached().catchError((_) => false); + if (!mounted) return; + setState(() { + _crossSigningEnabled = enc.crossSigning.enabled; + _crossSigningCached = cached; + _keyBackupEnabled = enc.keyManager.enabled; + _statusLoaded = true; + }); + } catch (_) {} + } + + Future _start() async { + setState(() { _state = _EncState.loading; _error = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + if (client.encryption == null) { + setState(() { _state = _EncState.error; _error = 'Verschlüsselung nicht verfügbar.'; }); + return; + } + final enc = client.encryption!; + final cached = await enc.keyManager.isCached().catchError((_) => false); + if (cached) { + await enc.keyManager.loadAllKeys().catchError((_) {}); + if (mounted) setState(() { _state = _EncState.done; _error = null; }); + await _loadStatus(); + return; + } + + await client.roomsLoading; + await client.accountDataLoading; + await client.userDeviceKeysLoading; + + final completer = Completer(); + _bootstrap = enc.bootstrap(onUpdate: (bs) { + if (!mounted) return; + switch (bs.state) { + case BootstrapState.askWipeSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(false)); + case BootstrapState.askBadSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); + case BootstrapState.askUseExistingSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(true)); + case BootstrapState.openExistingSsss: + if (!completer.isCompleted) completer.complete(true); + case BootstrapState.askNewSsss: + if (!completer.isCompleted) completer.complete(false); + case BootstrapState.error: + if (!completer.isCompleted) completer.complete(false); + if (mounted) setState(() { _state = _EncState.error; _error = 'Bootstrap-Fehler.'; }); + default: + break; + } + }); + + final needsKey = await completer.future + .timeout(const Duration(seconds: 30), onTimeout: () => false); + if (!mounted) return; + setState(() => _state = needsKey ? _EncState.needsKey : _EncState.done); + await _loadStatus(); + } catch (e) { + if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); + } + } + + Future _unlock() async { + final bs = _bootstrap; + if (bs == null || bs.newSsssKey == null) return; + final key = _keyCtrl.text.trim(); + if (key.isEmpty) { setState(() => _error = 'Bitte Wiederherstellungsschlüssel eingeben.'); return; } + setState(() { _state = _EncState.unlocking; _error = null; }); + try { + await bs.newSsssKey!.unlock(keyOrPassphrase: key); + await bs.openExistingSsss(); + final client = await ref.read(matrixClientProvider.future); + if (bs.encryption.crossSigning.enabled) { + await client.encryption!.crossSigning.selfSign(recoveryKey: key); + } + await client.encryption!.keyManager.loadAllKeys(); + if (mounted) setState(() => _state = _EncState.done); + await _loadStatus(); + } on InvalidPassphraseException catch (_) { + if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Falscher Schlüssel.'; }); + } on FormatException catch (_) { + if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Ungültiges Format.'; }); + } catch (e) { + if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); + } + } + + Future _resetSecurity() async { + final confirm = await showDialog( + context: context, + builder: (ctx) { + final pt = widget.pt; + return AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + title: Text('Sicherheit zurücksetzen?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text( + 'Dies löscht deine aktuellen Cross-Signing-Schlüssel und den Online-Schlüssel-Backup. Nicht gesicherte Sitzungen können Nachrichten möglicherweise nicht mehr entschlüsseln.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + 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('Zurücksetzen'), + ), + ], + ); + }, + ); + if (confirm != true || !mounted) return; + setState(() { _state = _EncState.loading; _error = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + final enc = client.encryption; + if (enc == null) throw Exception('Verschlüsselung nicht verfügbar'); + await client.roomsLoading; + await client.accountDataLoading; + await client.userDeviceKeysLoading; + final completer = Completer(); + enc.bootstrap(onUpdate: (bs) { + if (!mounted) return; + switch (bs.state) { + case BootstrapState.askWipeSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(true)); + case BootstrapState.askBadSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); + case BootstrapState.askUseExistingSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(false)); + case BootstrapState.askNewSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss()); + case BootstrapState.askWipeCrossSigning: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeCrossSigning(true)); + case BootstrapState.askSetupCrossSigning: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupCrossSigning( + setupMasterKey: true, setupSelfSigningKey: true, setupUserSigningKey: true)); + case BootstrapState.askWipeOnlineKeyBackup: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(true)); + case BootstrapState.askSetupOnlineKeyBackup: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true)); + case BootstrapState.done: + if (!completer.isCompleted) completer.complete(); + case BootstrapState.error: + if (!completer.isCompleted) completer.completeError('Bootstrap-Fehler'); + default: + break; + } + }); + await completer.future.timeout(const Duration(seconds: 60)); + if (mounted) setState(() => _state = _EncState.done); + await _loadStatus(); + } catch (e) { + if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); + } + } + + Future _showPassphraseDialog({required bool forExport}) { + return showDialog( + context: context, + builder: (ctx) => _PassphraseDialog(pt: widget.pt, forExport: forExport), + ); + } + + Future _exportKeys() async { + final passphrase = await _showPassphraseDialog(forExport: true); + if (passphrase == null || !mounted) return; + + setState(() { _exportLoading = true; _keyFileMsg = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + final db = client.database; + if (db == null) throw Exception('Datenbank nicht verfügbar.'); + + final stored = await db.getAllInboundGroupSessions(); + final sessions = >[]; + + for (final s in stored) { + try { + final content = jsonDecode(s.content) as Map; + final sessionKey = content['session_key'] as String?; + if (sessionKey == null || sessionKey.isEmpty) continue; + + final claimedKeys = s.senderClaimedKeys.isNotEmpty + ? (jsonDecode(s.senderClaimedKeys) as Map) + .map((k, v) => MapEntry(k, v.toString())) + : {}; + + sessions.add({ + 'algorithm': 'm.megolm.v1.aes-sha2', + 'forwarding_curve25519_key_chain': [], + 'room_id': s.roomId, + 'sender_key': s.senderKey, + 'sender_claimed_keys': claimedKeys, + 'session_id': s.sessionId, + 'session_key': sessionKey, + }); + } catch (_) { continue; } + } + + if (sessions.isEmpty) throw Exception('Keine exportierbaren Sitzungsschlüssel gefunden.'); + + final encrypted = await compute(_encryptMegolmKeys, { + 'passphrase': passphrase, + 'sessions': sessions, + }); + + final dir = await getApplicationDocumentsDirectory(); + final now = DateTime.now(); + final stamp = '${now.year}' + '${now.month.toString().padLeft(2, '0')}' + '${now.day.toString().padLeft(2, '0')}'; + final file = File('${dir.path}/pyramid_keys_$stamp.txt'); + await file.writeAsString(encrypted); + + if (mounted) setState(() { + _exportLoading = false; + _keyFileMsgIsError = false; + _keyFileMsg = '${sessions.length} Schlüssel exportiert → ${file.path}'; + }); + } catch (e) { + if (mounted) setState(() { + _exportLoading = false; + _keyFileMsgIsError = true; + _keyFileMsg = 'Export fehlgeschlagen: ${e.toString().split('\n').first}'; + }); + } + } + + Future _uploadDiagnostics() async { + setState(() { _diagUploading = true; _diagMsg = null; }); + try { + final notifier = ref.read(e2eeDiagnosticsProvider.notifier); + final msg = await notifier.upload( + serverUrl: 'https://dashboard.steggi-matrix.work', + token: 'J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI', + ); + if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = false; _diagMsg = msg; }); + } catch (e) { + if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = true; _diagMsg = e.toString(); }); + } + } + + Future _importKeys() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['txt', 'key', 'keys'], + allowMultiple: false, + ); + if (result == null || result.files.isEmpty || result.files.first.path == null) return; + if (!mounted) return; + + final passphrase = await _showPassphraseDialog(forExport: false); + if (passphrase == null || !mounted) return; + + setState(() { _importLoading = true; _keyFileMsg = null; }); + try { + final fileContent = await File(result.files.first.path!).readAsString(); + final trimmed = fileContent.trim(); + + if (!trimmed.startsWith(_kMegolmHeader) || !trimmed.endsWith(_kMegolmFooter)) { + throw Exception('Ungültiges Dateiformat – kein Megolm-Schlüsseldatei-Header.'); + } + + final lines = trimmed.split('\n'); + // Strip \r so Windows line endings (\r\n) don't corrupt the base64 string. + final b64 = lines.skip(1).take(lines.length - 2).join('').replaceAll(RegExp(r'\s'), ''); + final padded = b64 + '=' * ((4 - b64.length % 4) % 4); + final dataBytes = base64.decode(padded); + + final sessions = await compute(_decryptMegolmKeys, { + 'passphrase': passphrase, + 'data': dataBytes.toList(), + }); + + final client = await ref.read(matrixClientProvider.future); + final km = client.encryption?.keyManager; + if (km == null) throw Exception('Verschlüsselung nicht initialisiert.'); + + var imported = 0; + for (final raw in sessions) { + try { + final session = raw as Map; + final roomId = session['room_id'] as String?; + final sessionId = session['session_id'] as String?; + final senderKey = session['sender_key'] as String?; + if (roomId == null || sessionId == null || senderKey == null) continue; + + final claimedKeys = (session['sender_claimed_keys'] as Map?) + ?.map((k, v) => MapEntry(k.toString(), v.toString())) ?? + {}; + + await km.setInboundGroupSession( + roomId, sessionId, senderKey, + Map.from(session), + forwarded: true, + senderClaimedKeys: claimedKeys, + ); + imported++; + } catch (_) { continue; } + } + + if (mounted) setState(() { + _importLoading = false; + _keyFileMsgIsError = false; + _keyFileMsg = '$imported von ${sessions.length} Schlüssel importiert.'; + }); + } catch (e) { + if (mounted) setState(() { + _importLoading = false; + _keyFileMsgIsError = true; + _keyFileMsg = 'Import fehlgeschlagen: ${e.toString().split('\n').first}'; + }); + } + } + + Future _changeRecoveryKey() async { + final changed = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _ChangeRecoveryKeyDialog(pt: widget.pt), + ); + if (changed == true && mounted) { + await _loadStatus(); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Verschlüsselung', + subtitle: 'Ende-zu-Ende-Verschlüsselung & Sicherheit verwalten.', + pt: pt), + + // ── Status overview ─────────────────────────────────────────────── + if (_statusLoaded) ...[ + _SettingsGroup(title: 'Sicherheitsstatus', pt: pt, children: [ + _SettingsRow( + title: 'Cross-Signing', + desc: _crossSigningEnabled == true + ? (_crossSigningCached == true ? 'Eingerichtet & verifiziert' : 'Eingerichtet, Schlüssel nicht im Cache') + : 'Nicht eingerichtet', + pt: pt, + control: Icon( + _crossSigningEnabled == true && _crossSigningCached == true + ? Icons.verified_rounded + : (_crossSigningEnabled == true ? Icons.warning_amber_rounded : Icons.cancel_outlined), + size: 16, + color: _crossSigningEnabled == true && _crossSigningCached == true + ? PyramidColors.success + : (_crossSigningEnabled == true ? pt.away : pt.danger), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Online-Schlüsselbackup', + desc: _keyBackupEnabled == true ? 'Aktiv – Sitzungsschlüssel gesichert' : 'Nicht aktiviert', + pt: pt, + control: Icon( + _keyBackupEnabled == true ? Icons.cloud_done_rounded : Icons.cloud_off_rounded, + size: 16, + color: _keyBackupEnabled == true ? PyramidColors.success : pt.fgDim, + ), + ), + ]), + const SizedBox(height: 20), + _FingerprintGroup(pt: pt), + const SizedBox(height: 20), + ], + + // ── Key recovery / unlock ───────────────────────────────────────── + _SettingsGroup(title: 'Wiederherstellung', pt: pt, children: [ + _SettingsRow( + title: 'Schlüssel wiederherstellen', + desc: 'Nachrichten mit dem Wiederherstellungsschlüssel entschlüsseln', + pt: pt, + showArrow: true, + onTap: _state == _EncState.loading ? null : _start, + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Wiederherstellungsschlüssel ändern', + desc: 'Neuen Schlüssel generieren, notieren und aktivieren', + pt: pt, + showArrow: true, + onTap: _state == _EncState.loading ? null : _changeRecoveryKey, + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Sicherheit neu einrichten', + desc: 'Cross-Signing & Backup zurücksetzen und neu aufsetzen', + pt: pt, + showArrow: true, + destructive: true, + onTap: _state == _EncState.loading ? null : _resetSecurity, + ), + ]), + const SizedBox(height: 20), + + // ── Key file export / import ─────────────────────────────────────── + _SettingsGroup(title: 'Schlüsseldatei', pt: pt, children: [ + _SettingsRow( + title: 'Schlüssel exportieren', + desc: 'Sitzungsschlüssel als verschlüsselte Datei sichern (kompatibel mit Element)', + pt: pt, + showArrow: !_exportLoading, + control: _exportLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : null, + onTap: (_exportLoading || _importLoading) ? null : _exportKeys, + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Schlüssel importieren', + desc: 'Sitzungsschlüssel aus exportierter Datei laden', + pt: pt, + showArrow: !_importLoading, + control: _importLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : null, + onTap: (_exportLoading || _importLoading) ? null : _importKeys, + ), + ]), + + if (_keyFileMsg != null) ...[ + const SizedBox(height: 12), + _StatusCard( + pt: pt, + icon: _keyFileMsgIsError + ? Icons.error_outline_rounded + : Icons.check_circle_outline_rounded, + color: _keyFileMsgIsError ? pt.danger : PyramidColors.success, + text: _keyFileMsg!, + ), + ], + const SizedBox(height: 20), + + // ── E2EE Diagnostics ────────────────────────────────────────────── + Consumer(builder: (context, ref, _) { + final count = ref.watch(e2eeDiagnosticsProvider).length; + return _SettingsGroup(title: 'Diagnose', pt: pt, children: [ + _SettingsRow( + title: 'Entschlüsselungsfehler hochladen', + desc: count == 0 + ? 'Keine Fehler gesammelt' + : '$count Fehler gesammelt – zum Server hochladen', + pt: pt, + showArrow: !_diagUploading && count > 0, + control: _diagUploading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : count > 0 + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: pt.danger.withAlpha(30), + borderRadius: BorderRadius.circular(10), + ), + child: Text('$count', style: TextStyle(color: pt.danger, fontSize: 12)), + ) + : null, + onTap: (_diagUploading || count == 0) ? null : _uploadDiagnostics, + ), + ]); + }), + + if (_diagMsg != null) ...[ + const SizedBox(height: 12), + _StatusCard( + pt: pt, + icon: _diagMsgIsError + ? Icons.error_outline_rounded + : Icons.cloud_done_rounded, + color: _diagMsgIsError ? pt.danger : PyramidColors.success, + text: _diagMsg!, + ), + ], + const SizedBox(height: 20), + + // ── Inline state UI ─────────────────────────────────────────────── + if (_state == _EncState.loading) + const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive())), + + if (_state == _EncState.done) ...[ + _StatusCard( + pt: pt, + icon: Icons.check_circle_outline_rounded, + color: PyramidColors.success, + text: 'Alle Schlüssel importiert. Nachrichten werden entschlüsselt.'), + const SizedBox(height: 12), + ], + + if (_state == _EncState.needsKey || _state == _EncState.unlocking) ...[ + _StatusCard( + pt: pt, + icon: Icons.lock_outline_rounded, + color: pt.accent, + text: 'Wiederherstellungsschlüssel erforderlich.'), + const SizedBox(height: 16), + TextField( + controller: _keyCtrl, + readOnly: _state == _EncState.unlocking, + autofocus: true, + autocorrect: false, + style: TextStyle(color: pt.fg, fontSize: 13, fontFamily: 'monospace'), + decoration: InputDecoration( + hintText: 'EsXX XXXX XXXX XXXX …', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 12), + labelText: 'Wiederherstellungsschlüssel', + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + errorText: _error, + errorMaxLines: 2, + filled: true, + fillColor: pt.bg2, + prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + cursorColor: pt.accent, + onSubmitted: (_) => _unlock(), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: _PrimaryBtn( + label: _state == _EncState.unlocking ? 'Entschlüssele…' : 'Nachrichten entschlüsseln', + icon: Icons.lock_open_outlined, + pt: pt, + loading: _state == _EncState.unlocking, + onPressed: _unlock, + ), + ), + const SizedBox(height: 12), + ], + + if (_state == _EncState.error) ...[ + _StatusCard( + pt: pt, icon: Icons.error_outline_rounded, color: PyramidColors.danger, + text: _error ?? 'Unbekannter Fehler.'), + const SizedBox(height: 12), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: pt.fg, + side: BorderSide(color: pt.border), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _start, + icon: const Icon(Icons.refresh_rounded, size: 16), + label: const Text('Erneut versuchen'), + ), + ], + + if (_state == _EncState.idle && !_statusLoaded) + _StatusCard( + pt: pt, + icon: Icons.shield_outlined, + color: pt.fgMuted, + text: 'Lade Sicherheitsstatus…'), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// CHANGE RECOVERY KEY DIALOG +// ───────────────────────────────────────────────────────────────────────────── + +class _ChangeRecoveryKeyDialog extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _ChangeRecoveryKeyDialog({required this.pt}); + + @override + ConsumerState<_ChangeRecoveryKeyDialog> createState() => + _ChangeRecoveryKeyDialogState(); +} + +class _ChangeRecoveryKeyDialogState + extends ConsumerState<_ChangeRecoveryKeyDialog> { + _RecoveryStep _step = _RecoveryStep.loading; + String? _generatedKey; + String? _error; + bool _copied = false; + // Completer signals when bootstrap reaches `done` + final _doneCompleter = Completer(); + + @override + void initState() { + super.initState(); + _startBootstrap(); + } + + Future _startBootstrap() async { + try { + final client = await ref.read(matrixClientProvider.future); + final enc = client.encryption; + if (enc == null) throw Exception('Verschlüsselung nicht verfügbar.'); + await client.roomsLoading; + await client.accountDataLoading; + await client.userDeviceKeysLoading; + enc.bootstrap(onUpdate: (bs) { + if (!mounted) return; + _onBootstrapUpdate(bs); + }); + } catch (e) { + if (mounted) { + setState(() { + _step = _RecoveryStep.error; + _error = e.toString().split('\n').first; + }); + } + } + } + + // After newSsss() calls checkCrossSigning(), the bootstrap immediately + // transitions to askWipeCrossSigning (or askSetupCrossSigning). All post- + // newSsss states must therefore auto-advance unconditionally — guarding on + // _step would leave them stuck. + void _onBootstrapUpdate(Bootstrap bs) { + switch (bs.state) { + case BootstrapState.askWipeSsss: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askWipeSsss) bs.wipeSsss(true); + }); + case BootstrapState.askBadSsss: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askBadSsss) bs.ignoreBadSecrets(true); + }); + case BootstrapState.askUseExistingSsss: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askUseExistingSsss) bs.useExistingSsss(false); + }); + case BootstrapState.askUnlockSsss: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askUnlockSsss) bs.unlockedSsss(); + }); + case BootstrapState.askNewSsss: + // Only generate once + if (_step == _RecoveryStep.loading) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (bs.state != BootstrapState.askNewSsss) return; + try { + await bs.newSsss(); + // newSsss() internally calls checkCrossSigning() which already + // transitions to askWipeCrossSigning; capture key after it returns. + final key = bs.newSsssKey?.recoveryKey; + if (mounted) setState(() { _generatedKey = key; _step = _RecoveryStep.showKey; }); + } catch (e) { + if (mounted) setState(() { _step = _RecoveryStep.error; _error = e.toString().split('\n').first; }); + } + }); + } + case BootstrapState.askWipeCrossSigning: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askWipeCrossSigning) bs.wipeCrossSigning(false); + }); + case BootstrapState.askSetupCrossSigning: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askSetupCrossSigning) { + bs.askSetupCrossSigning( + setupMasterKey: true, + setupSelfSigningKey: true, + setupUserSigningKey: true, + ); + } + }); + case BootstrapState.askWipeOnlineKeyBackup: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askWipeOnlineKeyBackup) bs.wipeOnlineKeyBackup(true); + }); + case BootstrapState.askSetupOnlineKeyBackup: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askSetupOnlineKeyBackup) bs.askSetupOnlineKeyBackup(true); + }); + case BootstrapState.done: + if (!_doneCompleter.isCompleted) _doneCompleter.complete(); + // Only auto-advance to done if user already clicked through (applying). + // If still on showKey/confirmKey, the user must finish reading first. + if (mounted && _step == _RecoveryStep.applying) { + setState(() => _step = _RecoveryStep.done); + } + case BootstrapState.error: + if (!_doneCompleter.isCompleted) _doneCompleter.completeError('Bootstrap-Fehler'); + if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Fehler bei der Schlüsselgenerierung.'; }); + default: + break; + } + } + + // Called when user taps "Schlüssel gespeichert – weiter" on the showKey screen. + void _onKeySaved() { + setState(() => _step = _RecoveryStep.confirmKey); + } + + // Called when user submits the correct key confirmation. + // Bootstrap may still be running (cross-signing + key backup); wait for done. + Future _onKeyConfirmed() async { + setState(() => _step = _RecoveryStep.applying); + try { + await _doneCompleter.future.timeout(const Duration(seconds: 90)); + if (mounted) setState(() => _step = _RecoveryStep.done); + } catch (e) { + if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Timeout: Bootstrap abgebrochen.'; }); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final canClose = _step != _RecoveryStep.loading && _step != _RecoveryStep.applying && _step != _RecoveryStep.confirmKey; + + return Dialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440, minWidth: 320), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 12, 12), + child: Row( + children: [ + Icon(Icons.key_rounded, size: 18, color: pt.accent), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Wiederherstellungsschlüssel', + style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + if (canClose) + IconButton( + icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), + onPressed: () => Navigator.of(context).pop(_step == _RecoveryStep.done), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + ), + ], + ), + ), + Divider(color: pt.border, height: 1), + Padding( + padding: const EdgeInsets.all(20), + child: _buildBody(pt), + ), + ], + ), + ), + ); + } + + Widget _buildBody(PyramidTheme pt) { + return switch (_step) { + _RecoveryStep.loading => const Center( + child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive()), + ), + _RecoveryStep.showKey => _ShowKeyBody( + pt: pt, + generatedKey: _generatedKey ?? '', + copied: _copied, + onCopy: () { + Clipboard.setData(ClipboardData(text: _generatedKey ?? '')); + setState(() => _copied = true); + }, + onKeySaved: _onKeySaved, + ), + _RecoveryStep.confirmKey => _ConfirmKeyBody( + pt: pt, + expectedKey: _generatedKey ?? '', + onConfirmed: _onKeyConfirmed, + ), + _RecoveryStep.applying => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: 16), + Text( + 'Schlüssel wird aktiviert…\nCross-Signing und Backup werden eingerichtet.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + ], + ), + _RecoveryStep.done => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52), + const SizedBox(height: 12), + Text( + 'Wiederherstellungsschlüssel wurde erfolgreich geändert.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 6), + Text( + 'Bewahre deinen Schlüssel sicher auf – du brauchst ihn bei Geräteverlust.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.4), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Fertig'), + ), + ), + ], + ), + _RecoveryStep.error => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48), + const SizedBox(height: 12), + Text( + _error ?? 'Ein unbekannter Fehler ist aufgetreten.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: pt.fg, side: BorderSide(color: pt.border), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Schließen'), + ), + ), + ], + ), + }; + } +} + +class _ShowKeyBody extends StatelessWidget { + final PyramidTheme pt; + final String generatedKey; + final bool copied; + final VoidCallback onCopy; + final VoidCallback onKeySaved; + + const _ShowKeyBody({ + required this.pt, + required this.generatedKey, + required this.copied, + required this.onCopy, + required this.onKeySaved, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Dein neuer Wiederherstellungsschlüssel', + style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + 'Speichere diesen Schlüssel sicher – du brauchst ihn, wenn du den Zugang zu allen Geräten verlierst.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: pt.border), + ), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: SelectableText( + generatedKey, + style: TextStyle( + color: pt.accent, fontSize: 13, + fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6, + ), + ), + ), + ), + IconButton( + onPressed: onCopy, + icon: Icon( + copied ? Icons.check_rounded : Icons.copy_rounded, + size: 18, + color: copied ? PyramidColors.success : pt.fgDim, + ), + tooltip: copied ? 'Kopiert!' : 'In Zwischenablage kopieren', + ), + ], + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.orange.withAlpha(28), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.withAlpha(80)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, size: 16, color: Colors.orange.shade700), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Schreibe diesen Schlüssel auf oder speichere ihn in einem Passwort-Manager. Er kann nicht wiederhergestellt werden.', + style: TextStyle(color: Colors.orange.shade800, fontSize: 12, height: 1.4), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: onKeySaved, + child: const Text('Schlüssel gespeichert – weiter'), + ), + ), + ], + ); + } +} + +class _ConfirmKeyBody extends StatefulWidget { + final PyramidTheme pt; + final String expectedKey; + final VoidCallback onConfirmed; + + const _ConfirmKeyBody({ + required this.pt, + required this.expectedKey, + required this.onConfirmed, + }); + + @override + State<_ConfirmKeyBody> createState() => _ConfirmKeyBodyState(); +} + +class _ConfirmKeyBodyState extends State<_ConfirmKeyBody> { + final _controller = TextEditingController(); + bool _mismatch = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final entered = _controller.text.trim(); + if (entered == widget.expectedKey) { + widget.onConfirmed(); + } else { + setState(() => _mismatch = true); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Schlüssel bestätigen', + style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + 'Gib deinen neuen Wiederherstellungsschlüssel zur Bestätigung ein.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + const SizedBox(height: 16), + TextField( + controller: _controller, + autofocus: true, + style: TextStyle( + color: pt.fg, fontSize: 13, + fontFamily: 'monospace', letterSpacing: 0.4, + ), + decoration: InputDecoration( + hintText: 'EsTX XXXX XXXX …', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), + filled: true, + fillColor: pt.bg3, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: pt.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.accent, width: 1.5), + ), + errorText: _mismatch ? 'Schlüssel stimmt nicht überein.' : null, + ), + onChanged: (_) { if (_mismatch) setState(() => _mismatch = false); }, + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _submit, + child: const Text('Aktivieren'), + ), + ), + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// FINGERPRINT GROUP +// ───────────────────────────────────────────────────────────────────────────── + +class _FingerprintGroup extends ConsumerWidget { + final PyramidTheme pt; + const _FingerprintGroup({required this.pt}); + + static String _fmt(String key) { + final clean = key.replaceAll(RegExp(r'\s'), ''); + final groups = []; + for (var i = 0; i < clean.length; i += 4) { + groups.add(clean.substring(i, (i + 4).clamp(0, clean.length))); + } + return groups.join(' '); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientAsync = ref.watch(matrixClientProvider); + final client = clientAsync.valueOrNull; + if (client == null) return const SizedBox.shrink(); + + final fingerprint = _fmt(client.fingerprintKey); + final deviceId = client.deviceID ?? ''; + + return _SettingsGroup(title: 'Dieses Gerät', pt: pt, children: [ + _SettingsRow( + title: 'Geräte-ID', + desc: deviceId, + pt: pt, + control: IconButton( + icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), + tooltip: 'Kopieren', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + onPressed: () => Clipboard.setData(ClipboardData(text: deviceId)), + ), + ), + _Divider(pt: pt), + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Ed25519-Fingerabdruck', + style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), + const Spacer(), + IconButton( + icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), + tooltip: 'Fingerabdruck kopieren', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + onPressed: () => Clipboard.setData(ClipboardData(text: client.fingerprintKey)), + ), + ], + ), + const SizedBox(height: 4), + SelectableText( + fingerprint, + style: TextStyle( + color: pt.fgMuted, fontSize: 11, + fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6, + ), + ), + ], + ), + ), + ]); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// CHANGE PASSWORD DIALOG +// ───────────────────────────────────────────────────────────────────────────── + +class _ChangePasswordDialog extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _ChangePasswordDialog({required this.pt}); + + @override + ConsumerState<_ChangePasswordDialog> createState() => _ChangePasswordDialogState(); +} + +class _ChangePasswordDialogState extends ConsumerState<_ChangePasswordDialog> { + final _oldCtrl = TextEditingController(); + final _newCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + bool _oldVisible = false; + bool _newVisible = false; + bool _loading = false; + String? _error; + bool _done = false; + + @override + void dispose() { + _oldCtrl.dispose(); + _newCtrl.dispose(); + _confirmCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + final oldPw = _oldCtrl.text; + final newPw = _newCtrl.text; + final confirm = _confirmCtrl.text; + if (oldPw.isEmpty || newPw.isEmpty) { + setState(() => _error = 'Bitte alle Felder ausfüllen.'); + return; + } + if (newPw != confirm) { + setState(() => _error = 'Die neuen Passwörter stimmen nicht überein.'); + return; + } + if (newPw.length < 8) { + setState(() => _error = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.'); + return; + } + setState(() { _loading = true; _error = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + await client.changePassword(newPw, oldPassword: oldPw); + if (mounted) setState(() { _loading = false; _done = true; }); + } on MatrixException catch (e) { + if (mounted) setState(() { _loading = false; _error = e.errorMessage; }); + } catch (e) { + if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; }); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Dialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420, minWidth: 300), + child: Padding( + padding: const EdgeInsets.all(24), + child: _done ? _buildDone(pt) : _buildForm(pt), + ), + ), + ); + } + + Widget _buildDone(PyramidTheme pt) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52), + const SizedBox(height: 12), + Text('Passwort geändert', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + Text('Dein Passwort wurde erfolgreich aktualisiert.', + textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)), + const SizedBox(height: 20), + SizedBox(width: double.infinity, child: ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Schließen'), + )), + ], + ); + + Widget _buildForm(PyramidTheme pt) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.lock_outline_rounded, size: 18, color: pt.accent), + const SizedBox(width: 8), + Text('Passwort ändern', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), + const Spacer(), + IconButton( + icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + ), + ]), + const SizedBox(height: 20), + _PwField(ctrl: _oldCtrl, label: 'Aktuelles Passwort', pt: pt, + visible: _oldVisible, onToggle: () => setState(() => _oldVisible = !_oldVisible)), + const SizedBox(height: 12), + _PwField(ctrl: _newCtrl, label: 'Neues Passwort', pt: pt, + visible: _newVisible, onToggle: () => setState(() => _newVisible = !_newVisible)), + const SizedBox(height: 12), + _PwField(ctrl: _confirmCtrl, label: 'Neues Passwort bestätigen', pt: pt, + visible: _newVisible, onToggle: null, + onSubmitted: (_) => _submit()), + if (_error != null) ...[ + const SizedBox(height: 10), + _StatusCard(pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!), + ], + const SizedBox(height: 20), + SizedBox(width: double.infinity, child: ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: _loading ? null : _submit, + child: _loading + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) + : const Text('Passwort ändern'), + )), + ], + ); +} + +class _PwField extends StatelessWidget { + final TextEditingController ctrl; + final String label; + final PyramidTheme pt; + final bool visible; + final VoidCallback? onToggle; + final ValueChanged? onSubmitted; + + const _PwField({ + required this.ctrl, required this.label, required this.pt, + required this.visible, required this.onToggle, this.onSubmitted, + }); + + @override + Widget build(BuildContext context) => TextField( + controller: ctrl, + obscureText: !visible, + onSubmitted: onSubmitted, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + labelText: label, + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + filled: true, fillColor: pt.bg3, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), + suffixIcon: onToggle != null + ? IconButton( + icon: Icon(visible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim), + onPressed: onToggle, + ) + : null, + ), + cursorColor: pt.accent, + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// DELETE ACCOUNT DIALOG +// ───────────────────────────────────────────────────────────────────────────── + +class _DeleteAccountDialog extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _DeleteAccountDialog({required this.pt}); + + @override + ConsumerState<_DeleteAccountDialog> createState() => _DeleteAccountDialogState(); +} + +enum _DeleteStep { confirm, auth, deleting, error } + +class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> { + _DeleteStep _step = _DeleteStep.confirm; + final _pwCtrl = TextEditingController(); + bool _pwVisible = false; + bool _eraseData = true; + String? _error; + + @override + void dispose() { _pwCtrl.dispose(); super.dispose(); } + + Future _proceed() async { + setState(() { _step = _DeleteStep.deleting; _error = null; }); + final client = await ref.read(matrixClientProvider.future); + try { + await client.deactivateAccount(erase: _eraseData); + } on MatrixException catch (e) { + if (e.requireAdditionalAuthentication && mounted) { + // UIA required — try with password + try { + await client.deactivateAccount( + erase: _eraseData, + auth: AuthenticationData( + type: AuthenticationTypes.password, + session: e.session, + additionalFields: { + 'identifier': {'type': 'm.id.user', 'user': client.userID}, + 'password': _pwCtrl.text, + }, + ), + ); + } on MatrixException catch (e2) { + if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.errorMessage; }); + return; + } catch (e2) { + if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.toString().split('\n').first; }); + return; + } + } else { + if (mounted) setState(() { _step = _DeleteStep.error; _error = e.errorMessage; }); + return; + } + } catch (e) { + if (mounted) setState(() { _step = _DeleteStep.error; _error = e.toString().split('\n').first; }); + return; + } + // Konto wurde gelöscht — ausloggen + try { await client.logout(); } catch (_) {} + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Dialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440, minWidth: 300), + child: Padding( + padding: const EdgeInsets.all(24), + child: switch (_step) { + _DeleteStep.confirm => _buildConfirm(pt), + _DeleteStep.auth => _buildAuth(pt), + _DeleteStep.deleting => const Center( + child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()), + ), + _DeleteStep.error => _buildError(pt), + }, + ), + ), + ); + } + + Widget _buildConfirm(PyramidTheme pt) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.warning_rounded, size: 20, color: pt.danger), + const SizedBox(width: 8), + Text('Konto löschen', style: TextStyle(color: pt.danger, fontSize: 15, fontWeight: FontWeight.w700)), + const Spacer(), + IconButton( + icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + ), + ]), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.danger.withAlpha(20), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: pt.danger.withAlpha(60)), + ), + child: Text( + 'Diese Aktion ist unwiderruflich. Dein Konto, alle Sitzungen und ' + 'deine Mitgliedschaft in Räumen werden dauerhaft gelöscht.', + style: TextStyle(color: pt.fg, fontSize: 13, height: 1.5), + ), + ), + const SizedBox(height: 16), + GestureDetector( + onTap: () => setState(() => _eraseData = !_eraseData), + child: Row(children: [ + SizedBox( + width: 18, height: 18, + child: Checkbox( + value: _eraseData, + onChanged: (v) => setState(() => _eraseData = v ?? true), + activeColor: pt.accent, + side: BorderSide(color: pt.border), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + const SizedBox(width: 8), + Expanded(child: Text('Nachrichten und Daten vom Server löschen', + style: TextStyle(color: pt.fg, fontSize: 13))), + ]), + ), + const SizedBox(height: 20), + Row(children: [ + Expanded(child: OutlinedButton( + style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + )), + const SizedBox(width: 12), + Expanded(child: ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => setState(() => _step = _DeleteStep.auth), + child: const Text('Weiter'), + )), + ]), + ], + ); + + Widget _buildAuth(PyramidTheme pt) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.lock_outline_rounded, size: 18, color: pt.danger), + const SizedBox(width: 8), + Text('Konto löschen bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), + ]), + const SizedBox(height: 16), + Text('Gib dein Passwort ein, um die Löschung zu bestätigen.', + style: TextStyle(color: pt.fgMuted, fontSize: 13)), + const SizedBox(height: 16), + TextField( + controller: _pwCtrl, + autofocus: true, + obscureText: !_pwVisible, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + labelText: 'Passwort', + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + filled: true, fillColor: pt.bg3, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.danger)), + suffixIcon: IconButton( + icon: Icon(_pwVisible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim), + onPressed: () => setState(() => _pwVisible = !_pwVisible), + ), + ), + cursorColor: pt.danger, + onSubmitted: (_) => _proceed(), + ), + const SizedBox(height: 20), + Row(children: [ + Expanded(child: OutlinedButton( + style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => setState(() => _step = _DeleteStep.confirm), + child: const Text('Zurück'), + )), + const SizedBox(width: 12), + Expanded(child: ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: _proceed, + child: const Text('Konto endgültig löschen'), + )), + ]), + ], + ); + + Widget _buildError(PyramidTheme pt) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline_rounded, color: pt.danger, size: 48), + const SizedBox(height: 12), + Text(_error ?? 'Ein Fehler ist aufgetreten.', + textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)), + const SizedBox(height: 20), + SizedBox(width: double.infinity, child: OutlinedButton( + style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Schließen'), + )), + ], + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// ABOUT +// ───────────────────────────────────────────────────────────────────────────── + +class _AboutSection extends ConsumerWidget { + final PyramidTheme pt; + const _AboutSection({required this.pt}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientAsync = ref.watch(matrixClientProvider); + final client = clientAsync.valueOrNull; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle(title: 'Über Pyramid', subtitle: 'Version & Konto-Infos.', pt: pt), + _SettingsGroup(title: 'Version', pt: pt, children: [ + _SettingsRow( + title: 'Pyramid', + desc: '0.1.0 · Flutter · Matrix SDK', + pt: pt, + control: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: pt.accentSoft, + borderRadius: BorderRadius.circular(6)), + child: Text('Beta', + style: TextStyle( + color: pt.accent, fontSize: 10, fontWeight: FontWeight.w700)), + )), + _Divider(pt: pt), + _SettingsRow( + title: 'Matrix SDK', + desc: 'matrix-dart-sdk ^6.2.0', + pt: pt), + ]), + const SizedBox(height: 20), + _UpdatesSettingsGroup(pt: pt), + const SizedBox(height: 20), + _NotificationsSettingsGroup(pt: pt), + const SizedBox(height: 20), + if (client != null) + _SettingsGroup(title: 'Konto', pt: pt, children: [ + _SettingsRow( + title: 'Matrix-ID', + desc: client.userID ?? '', + pt: pt, + control: GestureDetector( + onTap: () => Clipboard.setData(ClipboardData(text: client.userID ?? '')), + child: Tooltip( + message: 'Kopieren', + child: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), + ), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Homeserver', + desc: client.homeserver?.toString() ?? '', + pt: pt, + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Gerät', + desc: client.deviceID ?? '', + pt: pt, + control: GestureDetector( + onTap: () => + Clipboard.setData(ClipboardData(text: client.deviceID ?? '')), + child: Tooltip( + message: 'Kopieren', + child: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), + ), + ), + ), + ]), + const SizedBox(height: 20), + _SettingsGroup(title: 'Konto-Aktionen', pt: pt, children: [ + _SettingsRow( + title: 'Abmelden', + desc: 'Auf diesem Gerät ausloggen', + pt: pt, + destructive: true, + showArrow: true, + onTap: () => _confirmLogout(context, ref), + ), + ]), + ], + ), + ); + } + + Future _confirmLogout(BuildContext context, WidgetRef ref) async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + title: Text('Abmelden?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text( + 'Du wirst auf diesem Gerät abgemeldet. Deine Nachrichten bleiben erhalten.', + 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('Abmelden'), + ), + ], + ), + ); + if (confirm == true) { + final client = await ref.read(matrixClientProvider.future); + await client.logout().catchError((_) {}); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// NOTIFICATIONS SETTINGS GROUP +// ───────────────────────────────────────────────────────────────────────────── + +class _NotificationsSettingsGroup extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _NotificationsSettingsGroup({required this.pt}); + @override + ConsumerState<_NotificationsSettingsGroup> createState() => + _NotificationsSettingsGroupState(); +} + +class _NotificationsSettingsGroupState + extends ConsumerState<_NotificationsSettingsGroup> { + bool _running = false; + String? _result; + bool _retryRunning = false; + String? _retryResult; + + Future _runDiag() async { + setState(() { _running = true; _result = null; }); + final client = await ref.read(matrixClientProvider.future); + final diag = await fcmDiagnostics(client); + if (mounted) setState(() { _running = false; _result = diag; }); + } + + Future _runRetry() async { + setState(() { _retryRunning = true; _retryResult = null; }); + final client = await ref.read(matrixClientProvider.future); + final result = await retryPusherRegistration(client); + if (mounted) setState(() { _retryRunning = false; _retryResult = result; }); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + if (!Platform.isAndroid) return const SizedBox.shrink(); + return _SettingsGroup(title: 'Push-Benachrichtigungen', pt: pt, children: [ + _SettingsRow( + title: 'Akku-Optimierung deaktivieren', + desc: 'Samsung / andere OEMs unterdrücken sonst Benachrichtigungen bei geschlossener App.', + pt: pt, + showArrow: true, + onTap: openBatteryOptimizationSettings, + control: Icon(Icons.battery_saver_outlined, size: 16, color: pt.fgDim), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'FCM Diagnose', + desc: _result ?? 'Prüft ob FCM-Token & Pusher korrekt registriert sind.', + pt: pt, + onTap: _running ? null : _runDiag, + control: _running + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent), + ) + : Icon(Icons.bug_report_outlined, size: 16, color: pt.fgDim), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Pusher neu registrieren', + desc: _retryResult ?? 'Pusher beim Homeserver erneut anmelden.', + pt: pt, + onTap: _retryRunning ? null : _runRetry, + control: _retryRunning + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent), + ) + : Icon(Icons.sync_outlined, size: 16, color: pt.fgDim), + ), + ]); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// UPDATE SETTINGS GROUP +// ───────────────────────────────────────────────────────────────────────────── + +class _UpdatesSettingsGroup extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _UpdatesSettingsGroup({required this.pt}); + + @override + ConsumerState<_UpdatesSettingsGroup> createState() => + _UpdatesSettingsGroupState(); +} + +class _UpdatesSettingsGroupState extends ConsumerState<_UpdatesSettingsGroup> { + bool _checking = false; + + Future _checkNow() async { + setState(() => _checking = true); + await resetUpdateCheckTimer(); + ref.invalidate(updateInfoProvider); + // Give the async provider a moment to resolve before resetting the spinner. + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _checking = false); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final updateAsync = ref.watch(updateInfoProvider); + + return _SettingsGroup(title: 'Updates', pt: pt, children: [ + updateAsync.when( + loading: () => _SettingsRow( + title: 'Nach Updates suchen…', + desc: 'Gitea Releases wird geprüft', + pt: pt, + control: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: pt.accent, + ), + ), + ), + error: (e, _) => _SettingsRow( + title: 'Update-Prüfung fehlgeschlagen', + desc: 'Keine Verbindung oder Gitea nicht erreichbar (nur im Heimnetz).', + pt: pt, + control: _checking + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: pt.accent), + ) + : GestureDetector( + onTap: _checkNow, + child: Tooltip( + message: 'Erneut prüfen', + child: Icon(Icons.refresh_rounded, + size: 16, color: pt.fgDim), + ), + ), + ), + data: (info) { + if (info == null) { + return _SettingsRow( + title: 'Pyramid ist aktuell', + desc: 'Du verwendest die neueste Version.', + pt: pt, + onTap: _checking ? null : _checkNow, + control: _checking + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: pt.accent), + ) + : Icon(Icons.refresh_rounded, size: 16, color: pt.fgDim), + ); + } + // New version available + return Column( + children: [ + _SettingsRow( + title: 'Update verfügbar: ${info.tagName}', + desc: info.releaseName, + pt: pt, + showArrow: true, + onTap: () async { + if (info.canDownload) { + await showUpdateDownloadDialog(context, info); + } else { + final uri = Uri.parse(info.htmlUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + }, + control: Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: pt.accent, + borderRadius: BorderRadius.circular(6)), + child: Text( + 'Neu', + style: TextStyle( + color: pt.accentFg, + fontSize: 10, + fontWeight: FontWeight.w700), + ), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Diese Version überspringen', + desc: '${info.tagName} nicht mehr anzeigen', + pt: pt, + onTap: () async { + await skipUpdateVersion(info.tagName); + ref.invalidate(updateInfoProvider); + }, + ), + ], + ); + }, + ), + ]); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Passphrase dialog (for key export/import) +// ───────────────────────────────────────────────────────────────────────────── + +class _PassphraseDialog extends StatefulWidget { + final PyramidTheme pt; + final bool forExport; + const _PassphraseDialog({required this.pt, required this.forExport}); + + @override + State<_PassphraseDialog> createState() => _PassphraseDialogState(); +} + +class _PassphraseDialogState extends State<_PassphraseDialog> { + final _ctrl1 = TextEditingController(); + final _ctrl2 = TextEditingController(); + bool _obscure = true; + String? _error; + + @override + void dispose() { + _ctrl1.dispose(); + _ctrl2.dispose(); + super.dispose(); + } + + void _submit() { + final pass = _ctrl1.text.trim(); + if (pass.isEmpty) { + setState(() => _error = 'Bitte ein Passwort eingeben.'); + return; + } + if (widget.forExport && pass != _ctrl2.text.trim()) { + setState(() => _error = 'Passwörter stimmen nicht überein.'); + return; + } + Navigator.pop(context, pass); + } + + InputDecoration _inputDeco(String label, PyramidTheme pt) => InputDecoration( + labelText: label, + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + errorText: _error, + filled: true, + fillColor: pt.bg3, + prefixIcon: Icon(Icons.lock_outline_rounded, color: pt.fgDim, size: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), + isDense: true, + errorMaxLines: 2, + suffixIcon: IconButton( + icon: Icon( + _obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined, + size: 18, + color: pt.fgDim, + ), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ); + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border), + ), + title: Text( + widget.forExport ? 'Schlüssel exportieren' : 'Schlüssel importieren', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700), + ), + content: SizedBox( + width: 340, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.forExport + ? 'Wähle ein Passwort zum Verschlüsseln der Schlüsseldatei.' + : 'Gib das Passwort ein, mit dem die Datei verschlüsselt wurde.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + const SizedBox(height: 16), + TextField( + controller: _ctrl1, + obscureText: _obscure, + autofocus: true, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: _inputDeco('Passwort', pt).copyWith( + // Only show error on confirm field if export, else here + errorText: widget.forExport ? null : _error, + ), + cursorColor: pt.accent, + onSubmitted: widget.forExport ? null : (_) => _submit(), + ), + if (widget.forExport) ...[ + const SizedBox(height: 12), + TextField( + controller: _ctrl2, + obscureText: _obscure, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: _inputDeco('Passwort bestätigen', pt), + cursorColor: pt.accent, + onSubmitted: (_) => _submit(), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _submit, + child: Text(widget.forExport ? 'Exportieren' : 'Importieren'), + ), + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Shared small widgets +// ───────────────────────────────────────────────────────────────────────────── + +class _StatusCard extends StatelessWidget { + final PyramidTheme pt; + final IconData icon; + final Color color; + final String text; + const _StatusCard( + {required this.pt, required this.icon, required this.color, required this.text}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: pt.border), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 13))), + ], + ), + ); + } +} diff --git a/lib/widgets/share_target_dialog.dart b/lib/widgets/share_target_dialog.dart new file mode 100644 index 0000000..ae76319 --- /dev/null +++ b/lib/widgets/share_target_dialog.dart @@ -0,0 +1,297 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/app_state.dart'; +import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/features/chat/attachment_dialog.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; + +/// "Teilen nach Pyramid": Raum-Picker für geteilte Inhalte (Text/Dateien). +/// Nach Auswahl wird der Raum geöffnet; Dateien laufen durch den +/// AttachmentDialog (mit Vorschau), Text wird direkt gesendet. +class ShareTargetDialog { + static Future show( + BuildContext context, + WidgetRef ref, + PendingShare share, + ) async { + final client = await ref.read(matrixClientProvider.future); + if (!context.mounted) return; + + final room = await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Teilen', + barrierColor: Colors.black54, + transitionDuration: const Duration(milliseconds: 180), + pageBuilder: (_, __, ___) => _ShareTargetPicker(client: client, share: share), + transitionBuilder: (_, anim, __, child) { + final curved = CurvedAnimation(parent: anim, curve: Curves.easeOutBack); + return FadeTransition( + opacity: anim, + child: ScaleTransition( + scale: Tween(begin: 0.94, end: 1.0).animate(curved), + child: child, + ), + ); + }, + ); + if (room == null || !context.mounted) return; + + // Zielraum öffnen. + ref.read(activeRoomIdProvider.notifier).state = room.id; + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + + var filesSent = false; + if (share.paths.isNotEmpty) { + final files = share.paths + .map(File.new) + .where((f) => f.existsSync()) + .toList(); + if (files.isNotEmpty) { + filesSent = await AttachmentDialog.show(context, files, room); + for (final f in files) { + f.delete().catchError((_) => f); + } + } + } + final text = share.text?.trim() ?? ''; + if (text.isNotEmpty && (share.paths.isEmpty || filesSent)) { + try { + await room.sendTextEvent(text); + } catch (_) {} + } + } +} + +class _ShareTargetPicker extends StatefulWidget { + final Client client; + final PendingShare share; + const _ShareTargetPicker({required this.client, required this.share}); + + @override + State<_ShareTargetPicker> createState() => _ShareTargetPickerState(); +} + +class _ShareTargetPickerState extends State<_ShareTargetPicker> { + String _filter = ''; + + List get _rooms { + final rooms = widget.client.rooms + .where((r) => + r.membership == Membership.join && + !r.isSpace && + (_filter.isEmpty || + r + .getLocalizedDisplayname() + .toLowerCase() + .contains(_filter.toLowerCase()))) + .toList() + // Zuletzt aktive Chats zuerst — wie bei Discord/WhatsApp-Share. + ..sort((a, b) => (b.lastEvent?.originServerTs ?? + DateTime.fromMillisecondsSinceEpoch(0)) + .compareTo(a.lastEvent?.originServerTs ?? + DateTime.fromMillisecondsSinceEpoch(0))); + return rooms; + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final size = MediaQuery.sizeOf(context); + final rooms = _rooms; + final share = widget.share; + final fileCount = share.paths.length; + + return Center( + child: Material( + color: Colors.transparent, + child: Container( + width: (size.width - 32).clamp(280.0, 440.0), + height: (size.height - 80).clamp(320.0, 560.0), + decoration: BoxDecoration( + color: pt.bg1, + borderRadius: BorderRadius.circular(pt.rXl), + border: Border.all(color: pt.border), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 40)], + ), + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 12, 4), + child: Row( + children: [ + Icon(Icons.share_rounded, size: 18, color: pt.accent), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Teilen nach…', + style: TextStyle( + color: pt.fg, + fontSize: 16, + fontWeight: FontWeight.w700), + ), + ), + IconButton( + icon: Icon(Icons.close_rounded, size: 18, color: pt.fgDim), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + // Vorschau des geteilten Inhalts + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 10), + child: Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + child: Row( + children: [ + Icon( + fileCount > 0 + ? Icons.attach_file_rounded + : Icons.notes_rounded, + size: 14, + color: pt.fgMuted, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + fileCount > 0 + ? '$fileCount Datei${fileCount == 1 ? '' : 'en'}' + '${(share.text?.isNotEmpty ?? false) ? ' + Text' : ''}' + : (share.text ?? ''), + style: TextStyle(color: pt.fgMuted, fontSize: 12), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + // Suche + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: TextField( + autofocus: false, + onChanged: (v) => setState(() => _filter = v), + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + hintText: 'Chat suchen…', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), + prefixIcon: + Icon(Icons.search_rounded, size: 16, color: pt.fgDim), + isDense: true, + filled: true, + fillColor: pt.bg2, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rBase), + borderSide: BorderSide(color: pt.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rBase), + borderSide: BorderSide(color: pt.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(pt.rBase), + borderSide: BorderSide(color: pt.accent), + ), + ), + ), + ), + // Raumliste + Expanded( + child: rooms.isEmpty + ? Center( + child: Text('Keine Chats gefunden', + style: TextStyle(color: pt.fgDim, fontSize: 13)), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + itemCount: rooms.length, + itemBuilder: (_, i) => + _RoomRow(room: rooms[i], pt: pt, client: widget.client), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _RoomRow extends StatelessWidget { + final Room room; + final PyramidTheme pt; + final Client client; + const _RoomRow({required this.room, required this.pt, required this.client}); + + @override + Widget build(BuildContext context) { + final name = room.getLocalizedDisplayname(); + final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; + return InkWell( + borderRadius: BorderRadius.circular(pt.rBase), + onTap: () => Navigator.of(context).pop(room), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + if (room.avatar != null) + MxcAvatar( + mxcUri: room.avatar, + client: client, + size: 30, + borderRadius: BorderRadius.circular(15), + placeholder: (_) => _initialCircle(initial), + ) + else + _initialCircle(initial), + const SizedBox(width: 12), + Expanded( + child: Text( + name, + style: TextStyle( + color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + if (room.isDirectChat) + Icon(Icons.person_outline_rounded, size: 14, color: pt.fgDim) + else + Icon(Icons.tag_rounded, size: 14, color: pt.fgDim), + ], + ), + ), + ); + } + + Widget _initialCircle(String initial) => Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: pt.accent.withAlpha(60), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + initial, + style: TextStyle( + color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600), + ), + ), + ); +} diff --git a/lib/widgets/update_download_dialog.dart b/lib/widgets/update_download_dialog.dart index c04fe93..7e127c6 100644 --- a/lib/widgets/update_download_dialog.dart +++ b/lib/widgets/update_download_dialog.dart @@ -8,7 +8,7 @@ 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'; +import 'package:pyramid/widgets/pyramid_loader.dart'; // ─── Entry point ────────────────────────────────────────────────────────────── @@ -36,10 +36,7 @@ class _UpdateDownloadDialog extends ConsumerStatefulWidget { _UpdateDownloadDialogState(); } -class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog> - with SingleTickerProviderStateMixin { - late final AnimationController _spinCtrl; - +class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog> { _DownloadState _state = _DownloadState.idle; double _progress = 0.0; int _received = 0; @@ -49,19 +46,9 @@ class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog> @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 { @@ -127,6 +114,22 @@ class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog> .invokeMethod('installApk', {'path': file.path}); if (mounted) setState(() => _state = _DownloadState.done); } + } on PlatformException catch (e) { + if (e.code == 'PERMISSION_REQUIRED') { + if (mounted) { + setState(() { + _state = _DownloadState.error; + _errorMsg = 'Bitte "Unbekannte Apps installieren" für Pyramid erlauben (Einstellungen geöffnet) — dann erneut versuchen.'; + }); + } + } else { + if (mounted) { + setState(() { + _state = _DownloadState.error; + _errorMsg = e.message ?? e.toString(); + }); + } + } } catch (e) { if (mounted) { setState(() { @@ -170,21 +173,24 @@ class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog> child: Column( mainAxisSize: MainAxisSize.min, children: [ - // ── Animated Pyramid ── - _AnimatedPyramid(ctrl: _spinCtrl, pt: pt), + // ── Loading animation ── + const PyramidLoader(size: 72), const SizedBox(height: 24), - // ── Title ── - Text( - _titleText(), - style: TextStyle( - color: pt.fg, - fontSize: 16, - fontWeight: FontWeight.w700, + // ── Title (hidden while actively downloading) ── + if (_state != _DownloadState.downloading && + _state != _DownloadState.installing) ...[ + Text( + _titleText(), + style: TextStyle( + color: pt.fg, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 6), + const SizedBox(height: 6), + ], Text( widget.info.tagName, style: TextStyle(color: pt.accent, fontSize: 13), @@ -285,28 +291,6 @@ class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog> } } -// ─── 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 { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index aa44afe..331f998 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,16 +6,21 @@ #include "generated_plugin_registrant.h" -#include +#include #include #include #include +#include +#include +#include #include +#include +#include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) dynamic_color_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); - dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); + g_autoptr(FlPluginRegistrar) desktop_drop_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin"); + desktop_drop_plugin_register_with_registrar(desktop_drop_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); @@ -25,7 +30,22 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) livekit_client_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "LiveKitPlugin"); live_kit_plugin_register_with_registrar(livekit_client_registrar); + g_autoptr(FlPluginRegistrar) local_notifier_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "LocalNotifierPlugin"); + local_notifier_plugin_register_with_registrar(local_notifier_registrar); + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); g_autoptr(FlPluginRegistrar) sqlcipher_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlcipher_flutter_libs_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) volume_controller_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "VolumeControllerPlugin"); + volume_controller_plugin_register_with_registrar(volume_controller_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 3c8cc63..e837b37 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,11 +3,16 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dynamic_color + desktop_drop flutter_secure_storage_linux flutter_webrtc livekit_client + local_notifier + media_kit_libs_linux + media_kit_video sqlcipher_flutter_libs + url_launcher_linux + volume_controller ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/loader icon/pyramid-loader.json b/loader icon/pyramid-loader.json new file mode 100644 index 0000000..e56fd73 --- /dev/null +++ b/loader icon/pyramid-loader.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":120,"w":200,"h":200,"nm":"PyramidLoader","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"face_0_slot_3","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[100],"h":1},{"t":2,"s":[100],"h":1},{"t":3,"s":[100],"h":1},{"t":4,"s":[100],"h":1},{"t":5,"s":[100],"h":1},{"t":6,"s":[100],"h":1},{"t":7,"s":[100],"h":1},{"t":8,"s":[100],"h":1},{"t":9,"s":[100],"h":1},{"t":10,"s":[100],"h":1},{"t":11,"s":[100],"h":1},{"t":12,"s":[100],"h":1},{"t":13,"s":[100],"h":1},{"t":14,"s":[100],"h":1},{"t":15,"s":[100],"h":1},{"t":16,"s":[100],"h":1},{"t":17,"s":[100],"h":1},{"t":18,"s":[100],"h":1},{"t":19,"s":[100],"h":1},{"t":20,"s":[100],"h":1},{"t":21,"s":[100],"h":1},{"t":22,"s":[100],"h":1},{"t":23,"s":[100],"h":1},{"t":24,"s":[100],"h":1},{"t":25,"s":[100],"h":1},{"t":26,"s":[100],"h":1},{"t":27,"s":[100],"h":1},{"t":28,"s":[100],"h":1},{"t":29,"s":[100],"h":1},{"t":30,"s":[100],"h":1},{"t":31,"s":[100],"h":1},{"t":32,"s":[100],"h":1},{"t":33,"s":[100],"h":1},{"t":34,"s":[100],"h":1},{"t":35,"s":[100],"h":1},{"t":36,"s":[100],"h":1},{"t":37,"s":[100],"h":1},{"t":38,"s":[100],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014843,171.41663917972903],[25.254606555010795,168.4823104778698]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331856,171.41663917972903],[27.051928158630545,165.11883425295738]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.280660659196656,171.41663917972903],[30.329202572241016,161.70386936171468]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513414,171.41663917972903],[35.23167595892863,158.32327808225233]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.545421763770435,171.41663917972903],[41.867430278900926,155.08151817465492]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112144,171.41663917972903],[50.290594742135035,152.10096254861588]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[60.48276971388455,149.51968659541475]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.021711443009764,171.41663917972903],[72.33361100630755,147.4873228087879]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530236,171.41663917972903],[85.62202576908322,146.15861054812373]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.76383848175135,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.15046474746714,166.49908237253814],[114.37797423091669,146.15861054812373],[114.37797423091669,146.15861054812373]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.035359501747664,161.95417685080264],[127.6663889936925,147.4873228087879],[127.6663889936925,147.4873228087879]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.97759368381941,157.9009504134384],[139.51723028611542,149.51968659541475],[139.51723028611542,149.51968659541475]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.524739673823944,154.41502126880033],[149.7094052578649,152.10096254861585],[149.7094052578649,152.10096254861585]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[52.23930132816764,151.53412935182934],[158.13256972109895,155.08151817465486],[158.13256972109895,155.08151817465486]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[61.71757321027329,149.26456770823847],[164.7683240410713,158.3232780822523],[164.7683240410713,158.3232780822523]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.60176086831838,147.5878230578733],[169.67079742775897,161.70386936171468],[169.67079742775897,161.70386936171468]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.58645024373618,146.4669291932127],[172.94807184136943,165.11883425295736],[172.94807184136943,165.11883425295736]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.42059682997896,145.85220900074034],[174.7453934449892,168.48231047786976],[174.7453934449892,168.48231047786976]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.9061668000921,145.6862229301544],[174.3461075040085,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[109.89444817484419,145.9078517026294],[167.00604580866076,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.28089606093508,146.4555207003181],[162.02428061713266,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.9992085169768,147.26962670455904],[158.59688923713986,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[133.01516676125698,148.29425928080326],[156.25574276290297,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.32062633606557,149.47832409788663],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[144.9279202573607,150.77617838683688],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.86483302362365,152.14788350454043],[153.29873006042072,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[154.1702249825266,153.55916933063904],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[157.89032775993167,154.98119240867067],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[161.07569026230578,156.39015609296857],[153.90410328496438,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[163.7787279457848,157.7668476672345],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[166.05181248302944,159.09613519733574],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.94583184319973,160.36645616899267],[156.46115967472622,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.5091497314232,161.56932089599852],[157.58326505555533,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.78689635032543,162.6988462564911],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[171.8205280053716,163.75132940997366],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.64760001130034,164.72486659520752],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.30170482167705,165.61901871326904],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.81253471130574,166.43452296328823],[164.04759992204185,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.20603531105112,167.17304813479808],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.50462260185213,167.836990104014],[166.61675437660261,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.72744150919286,168.42930348868117],[167.821185092542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.89064897156487,168.95336516806938],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.00770831106672,169.4128653744158],[169.99796493107303,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.08968496955248,169.81172223294573],[170.9492051502293,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.14553626674518,170.1540159102598],[171.7996075308126,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.18238987263604,170.44393888058158],[172.5457254570127,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.20580725120632,170.6857592030873],[173.18694334757177,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2200295079158,170.88379409754347],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.22820393443305,171.04239149313332],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23259015718264,171.16591759537795],[174.5136772126083,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2347452187699,171.25874886104174],[174.7787355115039,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23568720142907,171.32526708618758],[174.97042363235644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2360371788439,171.36985659529591],[175.0997505080749,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23613938850093,171.3969027679839],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2361595754449,171.41079135350373],[175.21906944484536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616148789233,171.41590820144467],[175.23402437178987,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"face_1_slot_3","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[100],"h":1},{"t":59,"s":[100],"h":1},{"t":60,"s":[100],"h":1},{"t":61,"s":[100],"h":1},{"t":62,"s":[100],"h":1},{"t":63,"s":[100],"h":1},{"t":64,"s":[100],"h":1},{"t":65,"s":[100],"h":1},{"t":66,"s":[100],"h":1},{"t":67,"s":[100],"h":1},{"t":68,"s":[100],"h":1},{"t":69,"s":[100],"h":1},{"t":70,"s":[100],"h":1},{"t":71,"s":[100],"h":1},{"t":72,"s":[100],"h":1},{"t":73,"s":[100],"h":1},{"t":74,"s":[100],"h":1},{"t":75,"s":[100],"h":1},{"t":76,"s":[100],"h":1},{"t":77,"s":[100],"h":1},{"t":78,"s":[100],"h":1},{"t":79,"s":[100],"h":1},{"t":80,"s":[100],"h":1},{"t":81,"s":[100],"h":1},{"t":82,"s":[100],"h":1},{"t":83,"s":[100],"h":1},{"t":84,"s":[100],"h":1},{"t":85,"s":[100],"h":1},{"t":86,"s":[100],"h":1},{"t":87,"s":[100],"h":1},{"t":88,"s":[100],"h":1},{"t":89,"s":[100],"h":1},{"t":90,"s":[100],"h":1},{"t":91,"s":[100],"h":1},{"t":92,"s":[100],"h":1},{"t":93,"s":[100],"h":1},{"t":94,"s":[100],"h":1},{"t":95,"s":[100],"h":1},{"t":96,"s":[100],"h":1},{"t":97,"s":[100],"h":1},{"t":98,"s":[100],"h":1},{"t":99,"s":[100],"h":1},{"t":100,"s":[100],"h":1},{"t":101,"s":[100],"h":1},{"t":102,"s":[100],"h":1},{"t":103,"s":[100],"h":1},{"t":104,"s":[100],"h":1},{"t":105,"s":[100],"h":1},{"t":106,"s":[100],"h":1},{"t":107,"s":[100],"h":1},{"t":108,"s":[100],"h":1},{"t":109,"s":[100],"h":1},{"t":110,"s":[100],"h":1},{"t":111,"s":[100],"h":1},{"t":112,"s":[100],"h":1},{"t":113,"s":[100],"h":1},{"t":114,"s":[100],"h":1},{"t":115,"s":[100],"h":1},{"t":116,"s":[100],"h":1},{"t":117,"s":[100],"h":1},{"t":118,"s":[100],"h":1},{"t":119,"s":[100],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.7659756282101,171.41663917972903],[24.763838512107654,171.4159082014447]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515457,171.41663917972903],[24.76384042455507,171.41079135350375]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[24.763860611499055,171.3969027679839]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192508,171.41663917972903],[24.7639628211561,171.36985659529594]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.02957636764353,171.41663917972903],[24.76431279857094,171.32526708618758]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496132,171.41663917972903],[24.765254781230098,171.25874886104174]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391742,171.41663917972903],[24.767409842817358,171.16591759537795]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087367,171.41663917972903],[24.771796065566974,171.04239149313335]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.27467983390227,171.41663917972903],[24.779970492084203,170.8837940975435]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.813056652428145,171.41663917972903],[24.794192748793662,170.68575920308734]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987264,171.41663917972903],[24.817610127363963,170.44393888058158]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187406,171.41663917972903],[24.854463733254804,170.1540159102598]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.050794849770682,171.41663917972903],[24.910315030447507,169.81172223294573]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926943,171.41663917972903],[24.992291688933264,169.41286537441582]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[25.109351028435142,168.95336516806938]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.17881490745795,171.41663917972903],[25.272558490807143,168.42930348868117]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339733,171.41663917972903],[25.495377398147852,167.83699010401403]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[25.793964688948876,167.17304813479808]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795812,171.41663917972903],[26.18746528869424,166.43452296328823]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312886,171.41663917972903],[26.698295178322923,165.61901871326907]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[27.352399988699673,164.72486659520754]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[28.179471994628386,163.75132940997366]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.20571106244855,171.41663917972903],[29.213103649674565,162.69884625649112]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444464,171.41663917972903],[30.49085026857678,161.56932089599854]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527375,171.41663917972903],[32.054168156800245,160.36645616899273]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009054,171.41663917972903],[33.948187516970535,159.09613519733577]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.4085587911411,171.41663917972903],[36.22127205421518,157.76684766723452]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.0958967150356,171.41663917972903],[38.92430973769417,156.39015609296857]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[42.10967224006833,154.9811924086707]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[45.82977501747337,153.55916933063907]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.701269939579305,171.41663917972903],[50.13516697637628,152.14788350454046]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[55.07207974263926,150.77617838683688]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042787,171.41663917972903],[60.67937366393438,149.47832409788663]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709705,171.41663917972903],[66.984833238743,148.29425928080326]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.40311076286019,171.41663917972903],[74.00079148302312,147.26962670455904]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.97571938286738,171.41663917972903],[81.71910393906488,146.4555207003181]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.99395419133931,171.41663917972903],[90.10555182515573,145.9078517026294]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.65389249599157,171.41663917972903],[99.09383319990782,145.6862229301544]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.254606555010795,168.4823104778698],[108.5794031700209,145.85220900074034],[108.5794031700209,145.85220900074034]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.051928158630545,165.11883425295738],[118.41354975626373,146.46692919321268],[118.41354975626373,146.46692919321268]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.329202572241016,161.70386936171468],[128.3982391316816,147.58782305787327],[128.3982391316816,147.58782305787327]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.23167595892863,158.32327808225233],[138.2824267897266,149.26456770823847],[138.2824267897266,149.26456770823847]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[41.867430278900926,155.08151817465492],[147.76069867183222,151.5341293518293],[147.76069867183222,151.5341293518293]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.290594742135035,152.10096254861588],[156.47526032617597,154.4150212688003],[156.47526032617597,154.4150212688003]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.48276971388455,149.51968659541475],[164.0224063161806,157.90095041343838],[164.0224063161806,157.90095041343838]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[72.33361100630755,147.4873228087879],[169.96464049825235,161.95417685080264],[169.96464049825235,161.95417685080264]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.62202576908322,146.15861054812373],[173.84953525253286,166.4990823725381],[173.84953525253286,166.4990823725381]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.37797423091669,146.15861054812373],[164.15776704046982,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[127.6663889936925,147.4873228087879],[157.9782885569902,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.51723028611542,149.51968659541475],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.7094052578649,152.10096254861585],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[158.13256972109895,155.08151817465486],[153.45457823622954,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.7683240410713,158.3232780822523],[154.92905150486584,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.67079742775897,161.70386936171468],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.94807184136943,165.11883425295736],[161.9568687706681,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.7453934449892,168.48231047786976],[167.93271906985146,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"face_2_slot_3","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[100],"h":1},{"t":50,"s":[100],"h":1},{"t":51,"s":[100],"h":1},{"t":52,"s":[100],"h":1},{"t":53,"s":[100],"h":1},{"t":54,"s":[100],"h":1},{"t":55,"s":[100],"h":1},{"t":56,"s":[100],"h":1},{"t":57,"s":[100],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838512107654,171.4159082014447],[100.00213723752422,145.68435644436695],[100.00213723752422,145.68435644436695]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76384042455507,171.41079135350375],[100.01709790004887,145.68435709846247],[100.01709790004887,145.68435709846247]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763860611499055,171.3969027679839],[100.05770540750385,145.68436400280396],[100.05770540750385,145.68436400280396]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.7639628211561,171.36985659529594],[100.1367831262165,145.68439896056552],[100.1367831262165,145.68439896056552]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76431279857094,171.32526708618758],[100.26715412914858,145.6845186598911],[100.26715412914858,145.6845186598911]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.765254781230098,171.25874886104174],[100.46164040852113,145.68484083693517],[100.46164040852113,145.68484083693517]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.767409842817358,171.16591759537795],[100.73306087154158,145.68557791140813],[100.73306087154158,145.68557791140813]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.771796065566974,171.04239149313335],[101.09422703281935,145.68707808794161],[101.09422703281935,145.68707808794161]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.779970492084203,170.8837940975435],[101.55793479589938,145.68987390647064],[101.55793479589938,145.68987390647064]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.794192748793662,170.68575920308734],[102.13695009169614,145.69473820474883],[102.13695009169614,145.69473820474883]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.817610127363963,170.44393888058158],[102.84398541468727,145.70274741992375],[102.84398541468727,145.70274741992375]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.854463733254804,170.1540159102598],[103.69166347102245,145.71535209549262],[103.69166347102245,145.71535209549262]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.910315030447507,169.81172223294573],[104.69246323090135,145.73445436416338],[104.69246323090135,145.73445436416338]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.992291688933264,169.41286537441582],[105.85864266890633,145.76249203264803],[105.85864266890633,145.76249203264803]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.109351028435142,168.95336516806938],[107.20213139409789,145.8025286847221],[107.20213139409789,145.8025286847221]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.272558490807143,168.42930348868117],[108.73438523824998,145.85834892439436],[108.73438523824998,145.85834892439436]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.495377398147852,167.83699010401403],[110.46619371861527,145.9345574790187],[110.46619371861527,145.9345574790187]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.793964688948876,167.17304813479808],[112.40743016966178,146.03668034701371],[112.40743016966178,146.03668034701371]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.18746528869424,166.43452296328823],[114.56673331575352,146.17126547853735],[114.56673331575352,146.17126547853735]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.698295178322923,165.61901871326907],[116.9511082303112,146.34597959060318],[116.9511082303112,146.34597959060318]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.352399988699673,164.72486659520754],[119.56543412751468,146.56969661159826],[119.56543412751468,146.56969661159826]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[28.179471994628386,163.75132940997366],[122.41186643342385,146.85257189760665],[122.41186643342385,146.85257189760665]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.213103649674565,162.69884625649112],[125.48912130867366,147.20609474441147],[125.48912130867366,147.20609474441147]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.49085026857678,161.56932089599854],[128.79163252777767,147.6431098261423],[128.79163252777767,147.6431098261423]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.054168156800245,160.36645616899273],[132.30857370939827,148.17779603433607],[132.30857370939827,148.17779603433607]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[33.948187516970535,159.09613519733577],[136.02274375594828,148.82558880736312],[136.02274375594828,148.82558880736312]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[36.22127205421518,157.76684766723452],[139.9093204855403,149.60302950658289],[139.9093204855403,149.60302950658289]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[38.92430973769417,156.39015609296857],[143.93449736804646,150.52752284250107],[143.93449736804646,150.52752284250107]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[42.10967224006833,154.9811924086707],[148.05403158786663,151.61698098210726],[148.05403158786663,151.61698098210726]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[45.82977501747337,153.55916933063907],[152.21174892054898,152.88933106722158],[152.21174892054898,152.88933106722158]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.13516697637628,152.14788350454046],[156.33807262878386,154.36186184207872],[156.33807262878386,154.36186184207872]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[55.07207974263926,150.77617838683688],[160.34867008765178,156.0503854539823],[160.34867008765178,156.0503854539823]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.67937366393438,149.47832409788663],[164.14334216845504,157.96819292461282],[164.14334216845504,157.96819292461282]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[66.984833238743,148.29425928080326],[167.60531609072166,160.12478711212304],[167.60531609072166,160.12478711212304]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.00079148302312,147.26962670455904],[170.60114132569498,162.52438615639866],[170.60114132569498,162.52438615639866]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.71910393906488,146.4555207003181],[172.98142804309384,165.16420448884634],[172.98142804309384,165.16420448884634]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[90.10555182515573,145.9078517026294],[174.58270506836902,168.0325385968404],[174.58270506836902,168.0325385968404]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.09383319990782,145.6862229301544],[175.2307042485341,171.10671188088455],[175.2307042485341,171.10671188088455]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.5794031700209,145.85220900074034],[167.93271906985157,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.41354975626373,146.46692919321268],[161.95686877066814,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.3982391316816,147.58782305787327],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[138.2824267897266,149.26456770823847],[154.92905150486587,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[147.76069867183222,151.5341293518293],[153.45457823622957,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.47526032617597,154.4150212688003],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.0224063161806,157.90095041343838],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.96464049825235,161.95417685080264],[157.97828855699024,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.84953525253286,166.4990823725381],[164.15776704046976,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.653892495991485,171.41663917972903],[24.76929575146589,171.10671188088457]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.993954191339256,171.41663917972903],[25.417294931630963,168.03253859684042]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.975719382867354,171.41663917972903],[27.01857195690613,165.16420448884637]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.403110762860145,171.41663917972903],[29.398858674304975,162.5243861563987]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709703,171.41663917972903],[32.394683909278314,160.12478711212307]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042786,171.41663917972903],[35.856657831544936,157.96819292461282]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[39.65132991234821,156.0503854539823]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.70126993957929,171.41663917972903],[43.661927371216095,154.36186184207872]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[47.78825107945101,152.88933106722158]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[51.94596841213336,151.61698098210726]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.095896715035614,171.41663917972903],[56.06550263195349,150.52752284250107]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.40855879114112,171.41663917972903],[60.09067951445965,149.6030295065829]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009055,171.41663917972903],[63.97725624405165,148.82558880736312]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527377,171.41663917972903],[67.69142629060164,148.1777960343361]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444467,171.41663917972903],[71.20836747222228,147.6431098261423]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.205711062448565,171.41663917972903],[74.5108786913263,147.20609474441147]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[77.58813356657613,146.85257189760665]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[80.43456587248531,146.56969661159826]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312914,171.41663917972903],[83.04889176968875,146.34597959060318]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795813,171.41663917972903],[85.43326668424646,146.17126547853735]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[87.59256983033822,146.03668034701371]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339737,171.41663917972903],[89.53380628138467,145.9345574790187]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.178814907457976,171.41663917972903],[91.26561476174996,145.85834892439436]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[92.79786860590211,145.8025286847221]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926972,171.41663917972903],[94.14135733109363,145.76249203264803]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.05079484977071,171.41663917972903],[95.30753676909862,145.73445436416338]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187392,171.41663917972903],[96.30833652897755,145.71535209549262]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987292,171.41663917972903],[97.1560145853127,145.70274741992375]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.81305665242823,171.41663917972903],[97.8630499083038,145.69473820474883]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.274679833902283,171.41663917972903],[98.44206520410059,145.68987390647064]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087395,171.41663917972903],[98.9057729671806,145.68707808794161]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391714,171.41663917972903],[99.26693912845845,145.68557791140813]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496118,171.41663917972903],[99.53835959147887,145.68484083693517]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.029576367643557,171.41663917972903],[99.7328458708514,145.6845186598911]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192511,171.41663917972903],[99.86321687378347,145.68439896056552]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[99.94229459249615,145.68436400280396]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515464,171.41663917972903],[99.98290209995109,145.6843570984625]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.765975628210114,171.41663917972903],[99.99786276247576,145.68435644436695]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751364,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"face_3_slot_3","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[100],"h":1},{"t":40,"s":[100],"h":1},{"t":41,"s":[100],"h":1},{"t":42,"s":[100],"h":1},{"t":43,"s":[100],"h":1},{"t":44,"s":[100],"h":1},{"t":45,"s":[100],"h":1},{"t":46,"s":[100],"h":1},{"t":47,"s":[100],"h":1},{"t":48,"s":[100],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.00213723752422,145.68435644436695],[175.2340243717899,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.01709790004887,145.68435709846247],[175.21906944484545,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.05770540750385,145.68436400280396],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.1367831262165,145.68439896056552],[175.09975050807492,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.26715412914858,145.6845186598911],[174.97042363235647,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.46164040852113,145.68484083693517],[174.77873551150387,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.73306087154158,145.68557791140813],[174.51367721260826,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.09422703281935,145.68707808794161],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.55793479589938,145.68987390647064],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.13695009169614,145.69473820474883],[173.18694334757186,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.84398541468727,145.70274741992375],[172.54572545701274,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[103.69166347102245,145.71535209549262],[171.79960753081258,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[104.69246323090135,145.73445436416338],[170.94920515022932,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[105.85864266890633,145.76249203264803],[169.99796493107306,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[107.20213139409789,145.8025286847221],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.73438523824998,145.85834892439436],[167.82118509254207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[110.46619371861527,145.9345574790187],[166.61675437660267,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[112.40743016966178,146.03668034701371],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.56673331575352,146.17126547853735],[164.04759992204188,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[116.9511082303112,146.34597959060318],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[119.56543412751468,146.56969661159826],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[122.41186643342385,146.85257189760665],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.48912130867366,147.20609474441147],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.79163252777767,147.6431098261423],[157.58326505555536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[132.30857370939827,148.17779603433607],[156.46115967472625,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[136.02274375594828,148.82558880736312],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.9093204855403,149.60302950658289],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[143.93449736804646,150.52752284250107],[153.9041032849644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[148.05403158786663,151.61698098210726],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[152.21174892054898,152.88933106722158],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.33807262878386,154.36186184207872],[153.2987300604207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[160.34867008765178,156.0503854539823],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.14334216845504,157.96819292461282],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.60531609072166,160.12478711212304],[156.25574276290294,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.60114132569498,162.52438615639866],[158.5968892371398,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.98142804309384,165.16420448884634],[162.0242806171326,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.58270506836902,168.0325385968404],[167.0060458086607,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2307042485341,171.10671188088455],[174.34610750400844,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530165,171.41663917972903],[26.15046474746714,166.49908237253814]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.02171144300978,171.41663917972903],[30.035359501747664,161.95417685080264]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[35.97759368381941,157.9009504134384]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112142,171.41663917972903],[43.524739673823944,154.41502126880033]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.54542176377046,171.41663917972903],[52.23930132816764,151.53412935182934]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513416,171.41663917972903],[61.71757321027329,149.26456770823847]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.28066065919667,171.41663917972903],[71.60176086831838,147.5878230578733]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331905,171.41663917972903],[81.58645024373618,146.4669291932127]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014854,171.41663917972903],[91.42059682997896,145.85220900074034]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76929575146589,171.10671188088457],[100.9061668000921,145.6862229301544],[100.9061668000921,145.6862229301544]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.417294931630963,168.03253859684042],[109.89444817484419,145.9078517026294],[109.89444817484419,145.9078517026294]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.01857195690613,165.16420448884637],[118.28089606093508,146.4555207003181],[118.28089606093508,146.4555207003181]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.398858674304975,162.5243861563987],[125.9992085169768,147.26962670455904],[125.9992085169768,147.26962670455904]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.394683909278314,160.12478711212307],[133.01516676125698,148.29425928080326],[133.01516676125698,148.29425928080326]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.856657831544936,157.96819292461282],[139.32062633606557,149.47832409788663],[139.32062633606557,149.47832409788663]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[39.65132991234821,156.0503854539823],[144.9279202573607,150.77617838683688],[144.9279202573607,150.77617838683688]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.661927371216095,154.36186184207872],[149.86483302362365,152.14788350454043],[149.86483302362365,152.14788350454043]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[47.78825107945101,152.88933106722158],[154.1702249825266,153.55916933063904],[154.1702249825266,153.55916933063904]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[51.94596841213336,151.61698098210726],[157.89032775993167,154.98119240867067],[157.89032775993167,154.98119240867067]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[56.06550263195349,150.52752284250107],[161.07569026230578,156.39015609296857],[161.07569026230578,156.39015609296857]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.09067951445965,149.6030295065829],[163.7787279457848,157.7668476672345],[163.7787279457848,157.7668476672345]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[63.97725624405165,148.82558880736312],[166.05181248302944,159.09613519733574],[166.05181248302944,159.09613519733574]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[67.69142629060164,148.1777960343361],[167.94583184319973,160.36645616899267],[167.94583184319973,160.36645616899267]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.20836747222228,147.6431098261423],[169.5091497314232,161.56932089599852],[169.5091497314232,161.56932089599852]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.5108786913263,147.20609474441147],[170.78689635032543,162.6988462564911],[170.78689635032543,162.6988462564911]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[77.58813356657613,146.85257189760665],[171.8205280053716,163.75132940997366],[171.8205280053716,163.75132940997366]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[80.43456587248531,146.56969661159826],[172.64760001130034,164.72486659520752],[172.64760001130034,164.72486659520752]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[83.04889176968875,146.34597959060318],[173.30170482167705,165.61901871326904],[173.30170482167705,165.61901871326904]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.43326668424646,146.17126547853735],[173.81253471130574,166.43452296328823],[173.81253471130574,166.43452296328823]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[87.59256983033822,146.03668034701371],[174.20603531105112,167.17304813479808],[174.20603531105112,167.17304813479808]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[89.53380628138467,145.9345574790187],[174.50462260185213,167.836990104014],[174.50462260185213,167.836990104014]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.26561476174996,145.85834892439436],[174.72744150919286,168.42930348868117],[174.72744150919286,168.42930348868117]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[92.79786860590211,145.8025286847221],[174.89064897156487,168.95336516806938],[174.89064897156487,168.95336516806938]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[94.14135733109363,145.76249203264803],[175.00770831106672,169.4128653744158],[175.00770831106672,169.4128653744158]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[95.30753676909862,145.73445436416338],[175.08968496955248,169.81172223294573],[175.08968496955248,169.81172223294573]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[96.30833652897755,145.71535209549262],[175.14553626674518,170.1540159102598],[175.14553626674518,170.1540159102598]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.1560145853127,145.70274741992375],[175.18238987263604,170.44393888058158],[175.18238987263604,170.44393888058158]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.8630499083038,145.69473820474883],[175.20580725120632,170.6857592030873],[175.20580725120632,170.6857592030873]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.44206520410059,145.68987390647064],[175.2200295079158,170.88379409754347],[175.2200295079158,170.88379409754347]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.9057729671806,145.68707808794161],[175.22820393443305,171.04239149313332],[175.22820393443305,171.04239149313332]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.26693912845845,145.68557791140813],[175.23259015718264,171.16591759537795],[175.23259015718264,171.16591759537795]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.53835959147887,145.68484083693517],[175.2347452187699,171.25874886104174],[175.2347452187699,171.25874886104174]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.7328458708514,145.6845186598911],[175.23568720142907,171.32526708618758],[175.23568720142907,171.32526708618758]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.86321687378347,145.68439896056552],[175.2360371788439,171.36985659529591],[175.2360371788439,171.36985659529591]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.94229459249615,145.68436400280396],[175.23613938850093,171.3969027679839],[175.23613938850093,171.3969027679839]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.98290209995109,145.6843570984625],[175.2361595754449,171.41079135350373],[175.2361595754449,171.41079135350373]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99786276247576,145.68435644436695],[175.23616148789233,171.41590820144467],[175.23616148789233,171.41590820144467]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"face_0_slot_2","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[100],"h":1},{"t":40,"s":[100],"h":1},{"t":41,"s":[100],"h":1},{"t":42,"s":[100],"h":1},{"t":43,"s":[100],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[100],"h":1},{"t":67,"s":[100],"h":1},{"t":68,"s":[100],"h":1},{"t":69,"s":[100],"h":1},{"t":70,"s":[100],"h":1},{"t":71,"s":[100],"h":1},{"t":72,"s":[100],"h":1},{"t":73,"s":[100],"h":1},{"t":74,"s":[100],"h":1},{"t":75,"s":[100],"h":1},{"t":76,"s":[100],"h":1},{"t":77,"s":[100],"h":1},{"t":78,"s":[100],"h":1},{"t":79,"s":[100],"h":1},{"t":80,"s":[100],"h":1},{"t":81,"s":[100],"h":1},{"t":82,"s":[100],"h":1},{"t":83,"s":[100],"h":1},{"t":84,"s":[100],"h":1},{"t":85,"s":[100],"h":1},{"t":86,"s":[100],"h":1},{"t":87,"s":[100],"h":1},{"t":88,"s":[100],"h":1},{"t":89,"s":[100],"h":1},{"t":90,"s":[100],"h":1},{"t":91,"s":[100],"h":1},{"t":92,"s":[100],"h":1},{"t":93,"s":[100],"h":1},{"t":94,"s":[100],"h":1},{"t":95,"s":[100],"h":1},{"t":96,"s":[100],"h":1},{"t":97,"s":[100],"h":1},{"t":98,"s":[100],"h":1},{"t":99,"s":[100],"h":1},{"t":100,"s":[100],"h":1},{"t":101,"s":[100],"h":1},{"t":102,"s":[100],"h":1},{"t":103,"s":[100],"h":1},{"t":104,"s":[100],"h":1},{"t":105,"s":[100],"h":1},{"t":106,"s":[100],"h":1},{"t":107,"s":[100],"h":1},{"t":108,"s":[100],"h":1},{"t":109,"s":[100],"h":1},{"t":110,"s":[100],"h":1},{"t":111,"s":[100],"h":1},{"t":112,"s":[100],"h":1},{"t":113,"s":[100],"h":1},{"t":114,"s":[100],"h":1},{"t":115,"s":[100],"h":1},{"t":116,"s":[100],"h":1},{"t":117,"s":[100],"h":1},{"t":118,"s":[100],"h":1},{"t":119,"s":[100],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014843,171.41663917972903],[25.254606555010795,168.4823104778698]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331856,171.41663917972903],[27.051928158630545,165.11883425295738]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.280660659196656,171.41663917972903],[30.329202572241016,161.70386936171468]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513414,171.41663917972903],[35.23167595892863,158.32327808225233]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.545421763770435,171.41663917972903],[41.867430278900926,155.08151817465492]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112144,171.41663917972903],[50.290594742135035,152.10096254861588]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[60.48276971388455,149.51968659541475]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.021711443009764,171.41663917972903],[72.33361100630755,147.4873228087879]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530236,171.41663917972903],[85.62202576908322,146.15861054812373]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.76383848175135,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.15046474746714,166.49908237253814],[114.37797423091669,146.15861054812373],[114.37797423091669,146.15861054812373]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.035359501747664,161.95417685080264],[127.6663889936925,147.4873228087879],[127.6663889936925,147.4873228087879]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.97759368381941,157.9009504134384],[139.51723028611542,149.51968659541475],[139.51723028611542,149.51968659541475]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.524739673823944,154.41502126880033],[149.7094052578649,152.10096254861585],[149.7094052578649,152.10096254861585]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[52.23930132816764,151.53412935182934],[158.13256972109895,155.08151817465486],[158.13256972109895,155.08151817465486]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[61.71757321027329,149.26456770823847],[164.7683240410713,158.3232780822523],[164.7683240410713,158.3232780822523]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.60176086831838,147.5878230578733],[169.67079742775897,161.70386936171468],[169.67079742775897,161.70386936171468]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.58645024373618,146.4669291932127],[172.94807184136943,165.11883425295736],[172.94807184136943,165.11883425295736]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.42059682997896,145.85220900074034],[174.7453934449892,168.48231047786976],[174.7453934449892,168.48231047786976]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.9061668000921,145.6862229301544],[174.3461075040085,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[109.89444817484419,145.9078517026294],[167.00604580866076,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.28089606093508,146.4555207003181],[162.02428061713266,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.9992085169768,147.26962670455904],[158.59688923713986,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[133.01516676125698,148.29425928080326],[156.25574276290297,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.32062633606557,149.47832409788663],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[144.9279202573607,150.77617838683688],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.86483302362365,152.14788350454043],[153.29873006042072,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[154.1702249825266,153.55916933063904],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[157.89032775993167,154.98119240867067],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[161.07569026230578,156.39015609296857],[153.90410328496438,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[163.7787279457848,157.7668476672345],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[166.05181248302944,159.09613519733574],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.94583184319973,160.36645616899267],[156.46115967472622,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.5091497314232,161.56932089599852],[157.58326505555533,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.78689635032543,162.6988462564911],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[171.8205280053716,163.75132940997366],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.64760001130034,164.72486659520752],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.30170482167705,165.61901871326904],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.81253471130574,166.43452296328823],[164.04759992204185,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.20603531105112,167.17304813479808],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.50462260185213,167.836990104014],[166.61675437660261,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.72744150919286,168.42930348868117],[167.821185092542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.89064897156487,168.95336516806938],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.00770831106672,169.4128653744158],[169.99796493107303,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.08968496955248,169.81172223294573],[170.9492051502293,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.14553626674518,170.1540159102598],[171.7996075308126,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.18238987263604,170.44393888058158],[172.5457254570127,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.20580725120632,170.6857592030873],[173.18694334757177,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2200295079158,170.88379409754347],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.22820393443305,171.04239149313332],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23259015718264,171.16591759537795],[174.5136772126083,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2347452187699,171.25874886104174],[174.7787355115039,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23568720142907,171.32526708618758],[174.97042363235644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2360371788439,171.36985659529591],[175.0997505080749,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23613938850093,171.3969027679839],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2361595754449,171.41079135350373],[175.21906944484536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616148789233,171.41590820144467],[175.23402437178987,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"face_1_slot_2","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[100],"h":1},{"t":2,"s":[100],"h":1},{"t":3,"s":[100],"h":1},{"t":4,"s":[100],"h":1},{"t":5,"s":[100],"h":1},{"t":6,"s":[100],"h":1},{"t":7,"s":[100],"h":1},{"t":8,"s":[100],"h":1},{"t":9,"s":[100],"h":1},{"t":10,"s":[100],"h":1},{"t":11,"s":[100],"h":1},{"t":12,"s":[100],"h":1},{"t":13,"s":[100],"h":1},{"t":14,"s":[100],"h":1},{"t":15,"s":[100],"h":1},{"t":16,"s":[100],"h":1},{"t":17,"s":[100],"h":1},{"t":18,"s":[100],"h":1},{"t":19,"s":[100],"h":1},{"t":20,"s":[100],"h":1},{"t":21,"s":[100],"h":1},{"t":22,"s":[100],"h":1},{"t":23,"s":[100],"h":1},{"t":24,"s":[100],"h":1},{"t":25,"s":[100],"h":1},{"t":26,"s":[100],"h":1},{"t":27,"s":[100],"h":1},{"t":28,"s":[100],"h":1},{"t":29,"s":[100],"h":1},{"t":30,"s":[100],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[100],"h":1},{"t":54,"s":[100],"h":1},{"t":55,"s":[100],"h":1},{"t":56,"s":[100],"h":1},{"t":57,"s":[100],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.7659756282101,171.41663917972903],[24.763838512107654,171.4159082014447]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515457,171.41663917972903],[24.76384042455507,171.41079135350375]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[24.763860611499055,171.3969027679839]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192508,171.41663917972903],[24.7639628211561,171.36985659529594]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.02957636764353,171.41663917972903],[24.76431279857094,171.32526708618758]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496132,171.41663917972903],[24.765254781230098,171.25874886104174]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391742,171.41663917972903],[24.767409842817358,171.16591759537795]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087367,171.41663917972903],[24.771796065566974,171.04239149313335]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.27467983390227,171.41663917972903],[24.779970492084203,170.8837940975435]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.813056652428145,171.41663917972903],[24.794192748793662,170.68575920308734]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987264,171.41663917972903],[24.817610127363963,170.44393888058158]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187406,171.41663917972903],[24.854463733254804,170.1540159102598]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.050794849770682,171.41663917972903],[24.910315030447507,169.81172223294573]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926943,171.41663917972903],[24.992291688933264,169.41286537441582]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[25.109351028435142,168.95336516806938]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.17881490745795,171.41663917972903],[25.272558490807143,168.42930348868117]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339733,171.41663917972903],[25.495377398147852,167.83699010401403]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[25.793964688948876,167.17304813479808]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795812,171.41663917972903],[26.18746528869424,166.43452296328823]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312886,171.41663917972903],[26.698295178322923,165.61901871326907]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[27.352399988699673,164.72486659520754]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[28.179471994628386,163.75132940997366]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.20571106244855,171.41663917972903],[29.213103649674565,162.69884625649112]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444464,171.41663917972903],[30.49085026857678,161.56932089599854]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527375,171.41663917972903],[32.054168156800245,160.36645616899273]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009054,171.41663917972903],[33.948187516970535,159.09613519733577]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.4085587911411,171.41663917972903],[36.22127205421518,157.76684766723452]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.0958967150356,171.41663917972903],[38.92430973769417,156.39015609296857]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[42.10967224006833,154.9811924086707]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[45.82977501747337,153.55916933063907]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.701269939579305,171.41663917972903],[50.13516697637628,152.14788350454046]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[55.07207974263926,150.77617838683688]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042787,171.41663917972903],[60.67937366393438,149.47832409788663]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709705,171.41663917972903],[66.984833238743,148.29425928080326]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.40311076286019,171.41663917972903],[74.00079148302312,147.26962670455904]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.97571938286738,171.41663917972903],[81.71910393906488,146.4555207003181]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.99395419133931,171.41663917972903],[90.10555182515573,145.9078517026294]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.65389249599157,171.41663917972903],[99.09383319990782,145.6862229301544]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.254606555010795,168.4823104778698],[108.5794031700209,145.85220900074034],[108.5794031700209,145.85220900074034]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.051928158630545,165.11883425295738],[118.41354975626373,146.46692919321268],[118.41354975626373,146.46692919321268]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.329202572241016,161.70386936171468],[128.3982391316816,147.58782305787327],[128.3982391316816,147.58782305787327]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.23167595892863,158.32327808225233],[138.2824267897266,149.26456770823847],[138.2824267897266,149.26456770823847]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[41.867430278900926,155.08151817465492],[147.76069867183222,151.5341293518293],[147.76069867183222,151.5341293518293]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.290594742135035,152.10096254861588],[156.47526032617597,154.4150212688003],[156.47526032617597,154.4150212688003]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.48276971388455,149.51968659541475],[164.0224063161806,157.90095041343838],[164.0224063161806,157.90095041343838]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[72.33361100630755,147.4873228087879],[169.96464049825235,161.95417685080264],[169.96464049825235,161.95417685080264]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.62202576908322,146.15861054812373],[173.84953525253286,166.4990823725381],[173.84953525253286,166.4990823725381]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.37797423091669,146.15861054812373],[164.15776704046982,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[127.6663889936925,147.4873228087879],[157.9782885569902,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.51723028611542,149.51968659541475],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.7094052578649,152.10096254861585],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[158.13256972109895,155.08151817465486],[153.45457823622954,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.7683240410713,158.3232780822523],[154.92905150486584,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.67079742775897,161.70386936171468],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.94807184136943,165.11883425295736],[161.9568687706681,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.7453934449892,168.48231047786976],[167.93271906985146,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"face_2_slot_2","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[100],"h":1},{"t":45,"s":[100],"h":1},{"t":46,"s":[100],"h":1},{"t":47,"s":[100],"h":1},{"t":48,"s":[100],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[100],"h":1},{"t":59,"s":[100],"h":1},{"t":60,"s":[100],"h":1},{"t":61,"s":[100],"h":1},{"t":62,"s":[100],"h":1},{"t":63,"s":[100],"h":1},{"t":64,"s":[100],"h":1},{"t":65,"s":[100],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838512107654,171.4159082014447],[100.00213723752422,145.68435644436695],[100.00213723752422,145.68435644436695]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76384042455507,171.41079135350375],[100.01709790004887,145.68435709846247],[100.01709790004887,145.68435709846247]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763860611499055,171.3969027679839],[100.05770540750385,145.68436400280396],[100.05770540750385,145.68436400280396]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.7639628211561,171.36985659529594],[100.1367831262165,145.68439896056552],[100.1367831262165,145.68439896056552]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76431279857094,171.32526708618758],[100.26715412914858,145.6845186598911],[100.26715412914858,145.6845186598911]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.765254781230098,171.25874886104174],[100.46164040852113,145.68484083693517],[100.46164040852113,145.68484083693517]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.767409842817358,171.16591759537795],[100.73306087154158,145.68557791140813],[100.73306087154158,145.68557791140813]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.771796065566974,171.04239149313335],[101.09422703281935,145.68707808794161],[101.09422703281935,145.68707808794161]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.779970492084203,170.8837940975435],[101.55793479589938,145.68987390647064],[101.55793479589938,145.68987390647064]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.794192748793662,170.68575920308734],[102.13695009169614,145.69473820474883],[102.13695009169614,145.69473820474883]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.817610127363963,170.44393888058158],[102.84398541468727,145.70274741992375],[102.84398541468727,145.70274741992375]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.854463733254804,170.1540159102598],[103.69166347102245,145.71535209549262],[103.69166347102245,145.71535209549262]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.910315030447507,169.81172223294573],[104.69246323090135,145.73445436416338],[104.69246323090135,145.73445436416338]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.992291688933264,169.41286537441582],[105.85864266890633,145.76249203264803],[105.85864266890633,145.76249203264803]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.109351028435142,168.95336516806938],[107.20213139409789,145.8025286847221],[107.20213139409789,145.8025286847221]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.272558490807143,168.42930348868117],[108.73438523824998,145.85834892439436],[108.73438523824998,145.85834892439436]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.495377398147852,167.83699010401403],[110.46619371861527,145.9345574790187],[110.46619371861527,145.9345574790187]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.793964688948876,167.17304813479808],[112.40743016966178,146.03668034701371],[112.40743016966178,146.03668034701371]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.18746528869424,166.43452296328823],[114.56673331575352,146.17126547853735],[114.56673331575352,146.17126547853735]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.698295178322923,165.61901871326907],[116.9511082303112,146.34597959060318],[116.9511082303112,146.34597959060318]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.352399988699673,164.72486659520754],[119.56543412751468,146.56969661159826],[119.56543412751468,146.56969661159826]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[28.179471994628386,163.75132940997366],[122.41186643342385,146.85257189760665],[122.41186643342385,146.85257189760665]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.213103649674565,162.69884625649112],[125.48912130867366,147.20609474441147],[125.48912130867366,147.20609474441147]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.49085026857678,161.56932089599854],[128.79163252777767,147.6431098261423],[128.79163252777767,147.6431098261423]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.054168156800245,160.36645616899273],[132.30857370939827,148.17779603433607],[132.30857370939827,148.17779603433607]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[33.948187516970535,159.09613519733577],[136.02274375594828,148.82558880736312],[136.02274375594828,148.82558880736312]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[36.22127205421518,157.76684766723452],[139.9093204855403,149.60302950658289],[139.9093204855403,149.60302950658289]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[38.92430973769417,156.39015609296857],[143.93449736804646,150.52752284250107],[143.93449736804646,150.52752284250107]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[42.10967224006833,154.9811924086707],[148.05403158786663,151.61698098210726],[148.05403158786663,151.61698098210726]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[45.82977501747337,153.55916933063907],[152.21174892054898,152.88933106722158],[152.21174892054898,152.88933106722158]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.13516697637628,152.14788350454046],[156.33807262878386,154.36186184207872],[156.33807262878386,154.36186184207872]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[55.07207974263926,150.77617838683688],[160.34867008765178,156.0503854539823],[160.34867008765178,156.0503854539823]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.67937366393438,149.47832409788663],[164.14334216845504,157.96819292461282],[164.14334216845504,157.96819292461282]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[66.984833238743,148.29425928080326],[167.60531609072166,160.12478711212304],[167.60531609072166,160.12478711212304]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.00079148302312,147.26962670455904],[170.60114132569498,162.52438615639866],[170.60114132569498,162.52438615639866]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.71910393906488,146.4555207003181],[172.98142804309384,165.16420448884634],[172.98142804309384,165.16420448884634]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[90.10555182515573,145.9078517026294],[174.58270506836902,168.0325385968404],[174.58270506836902,168.0325385968404]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.09383319990782,145.6862229301544],[175.2307042485341,171.10671188088455],[175.2307042485341,171.10671188088455]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.5794031700209,145.85220900074034],[167.93271906985157,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.41354975626373,146.46692919321268],[161.95686877066814,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.3982391316816,147.58782305787327],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[138.2824267897266,149.26456770823847],[154.92905150486587,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[147.76069867183222,151.5341293518293],[153.45457823622957,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.47526032617597,154.4150212688003],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.0224063161806,157.90095041343838],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.96464049825235,161.95417685080264],[157.97828855699024,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.84953525253286,166.4990823725381],[164.15776704046976,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.653892495991485,171.41663917972903],[24.76929575146589,171.10671188088457]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.993954191339256,171.41663917972903],[25.417294931630963,168.03253859684042]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.975719382867354,171.41663917972903],[27.01857195690613,165.16420448884637]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.403110762860145,171.41663917972903],[29.398858674304975,162.5243861563987]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709703,171.41663917972903],[32.394683909278314,160.12478711212307]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042786,171.41663917972903],[35.856657831544936,157.96819292461282]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[39.65132991234821,156.0503854539823]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.70126993957929,171.41663917972903],[43.661927371216095,154.36186184207872]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[47.78825107945101,152.88933106722158]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[51.94596841213336,151.61698098210726]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.095896715035614,171.41663917972903],[56.06550263195349,150.52752284250107]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.40855879114112,171.41663917972903],[60.09067951445965,149.6030295065829]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009055,171.41663917972903],[63.97725624405165,148.82558880736312]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527377,171.41663917972903],[67.69142629060164,148.1777960343361]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444467,171.41663917972903],[71.20836747222228,147.6431098261423]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.205711062448565,171.41663917972903],[74.5108786913263,147.20609474441147]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[77.58813356657613,146.85257189760665]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[80.43456587248531,146.56969661159826]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312914,171.41663917972903],[83.04889176968875,146.34597959060318]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795813,171.41663917972903],[85.43326668424646,146.17126547853735]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[87.59256983033822,146.03668034701371]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339737,171.41663917972903],[89.53380628138467,145.9345574790187]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.178814907457976,171.41663917972903],[91.26561476174996,145.85834892439436]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[92.79786860590211,145.8025286847221]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926972,171.41663917972903],[94.14135733109363,145.76249203264803]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.05079484977071,171.41663917972903],[95.30753676909862,145.73445436416338]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187392,171.41663917972903],[96.30833652897755,145.71535209549262]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987292,171.41663917972903],[97.1560145853127,145.70274741992375]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.81305665242823,171.41663917972903],[97.8630499083038,145.69473820474883]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.274679833902283,171.41663917972903],[98.44206520410059,145.68987390647064]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087395,171.41663917972903],[98.9057729671806,145.68707808794161]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391714,171.41663917972903],[99.26693912845845,145.68557791140813]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496118,171.41663917972903],[99.53835959147887,145.68484083693517]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.029576367643557,171.41663917972903],[99.7328458708514,145.6845186598911]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192511,171.41663917972903],[99.86321687378347,145.68439896056552]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[99.94229459249615,145.68436400280396]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515464,171.41663917972903],[99.98290209995109,145.6843570984625]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.765975628210114,171.41663917972903],[99.99786276247576,145.68435644436695]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751364,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"face_3_slot_2","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[100],"h":1},{"t":32,"s":[100],"h":1},{"t":33,"s":[100],"h":1},{"t":34,"s":[100],"h":1},{"t":35,"s":[100],"h":1},{"t":36,"s":[100],"h":1},{"t":37,"s":[100],"h":1},{"t":38,"s":[100],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[100],"h":1},{"t":50,"s":[100],"h":1},{"t":51,"s":[100],"h":1},{"t":52,"s":[100],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.00213723752422,145.68435644436695],[175.2340243717899,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.01709790004887,145.68435709846247],[175.21906944484545,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.05770540750385,145.68436400280396],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.1367831262165,145.68439896056552],[175.09975050807492,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.26715412914858,145.6845186598911],[174.97042363235647,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.46164040852113,145.68484083693517],[174.77873551150387,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.73306087154158,145.68557791140813],[174.51367721260826,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.09422703281935,145.68707808794161],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.55793479589938,145.68987390647064],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.13695009169614,145.69473820474883],[173.18694334757186,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.84398541468727,145.70274741992375],[172.54572545701274,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[103.69166347102245,145.71535209549262],[171.79960753081258,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[104.69246323090135,145.73445436416338],[170.94920515022932,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[105.85864266890633,145.76249203264803],[169.99796493107306,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[107.20213139409789,145.8025286847221],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.73438523824998,145.85834892439436],[167.82118509254207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[110.46619371861527,145.9345574790187],[166.61675437660267,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[112.40743016966178,146.03668034701371],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.56673331575352,146.17126547853735],[164.04759992204188,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[116.9511082303112,146.34597959060318],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[119.56543412751468,146.56969661159826],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[122.41186643342385,146.85257189760665],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.48912130867366,147.20609474441147],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.79163252777767,147.6431098261423],[157.58326505555536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[132.30857370939827,148.17779603433607],[156.46115967472625,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[136.02274375594828,148.82558880736312],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.9093204855403,149.60302950658289],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[143.93449736804646,150.52752284250107],[153.9041032849644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[148.05403158786663,151.61698098210726],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[152.21174892054898,152.88933106722158],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.33807262878386,154.36186184207872],[153.2987300604207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[160.34867008765178,156.0503854539823],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.14334216845504,157.96819292461282],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.60531609072166,160.12478711212304],[156.25574276290294,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.60114132569498,162.52438615639866],[158.5968892371398,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.98142804309384,165.16420448884634],[162.0242806171326,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.58270506836902,168.0325385968404],[167.0060458086607,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2307042485341,171.10671188088455],[174.34610750400844,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530165,171.41663917972903],[26.15046474746714,166.49908237253814]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.02171144300978,171.41663917972903],[30.035359501747664,161.95417685080264]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[35.97759368381941,157.9009504134384]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112142,171.41663917972903],[43.524739673823944,154.41502126880033]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.54542176377046,171.41663917972903],[52.23930132816764,151.53412935182934]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513416,171.41663917972903],[61.71757321027329,149.26456770823847]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.28066065919667,171.41663917972903],[71.60176086831838,147.5878230578733]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331905,171.41663917972903],[81.58645024373618,146.4669291932127]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014854,171.41663917972903],[91.42059682997896,145.85220900074034]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76929575146589,171.10671188088457],[100.9061668000921,145.6862229301544],[100.9061668000921,145.6862229301544]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.417294931630963,168.03253859684042],[109.89444817484419,145.9078517026294],[109.89444817484419,145.9078517026294]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.01857195690613,165.16420448884637],[118.28089606093508,146.4555207003181],[118.28089606093508,146.4555207003181]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.398858674304975,162.5243861563987],[125.9992085169768,147.26962670455904],[125.9992085169768,147.26962670455904]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.394683909278314,160.12478711212307],[133.01516676125698,148.29425928080326],[133.01516676125698,148.29425928080326]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.856657831544936,157.96819292461282],[139.32062633606557,149.47832409788663],[139.32062633606557,149.47832409788663]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[39.65132991234821,156.0503854539823],[144.9279202573607,150.77617838683688],[144.9279202573607,150.77617838683688]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.661927371216095,154.36186184207872],[149.86483302362365,152.14788350454043],[149.86483302362365,152.14788350454043]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[47.78825107945101,152.88933106722158],[154.1702249825266,153.55916933063904],[154.1702249825266,153.55916933063904]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[51.94596841213336,151.61698098210726],[157.89032775993167,154.98119240867067],[157.89032775993167,154.98119240867067]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[56.06550263195349,150.52752284250107],[161.07569026230578,156.39015609296857],[161.07569026230578,156.39015609296857]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.09067951445965,149.6030295065829],[163.7787279457848,157.7668476672345],[163.7787279457848,157.7668476672345]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[63.97725624405165,148.82558880736312],[166.05181248302944,159.09613519733574],[166.05181248302944,159.09613519733574]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[67.69142629060164,148.1777960343361],[167.94583184319973,160.36645616899267],[167.94583184319973,160.36645616899267]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.20836747222228,147.6431098261423],[169.5091497314232,161.56932089599852],[169.5091497314232,161.56932089599852]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.5108786913263,147.20609474441147],[170.78689635032543,162.6988462564911],[170.78689635032543,162.6988462564911]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[77.58813356657613,146.85257189760665],[171.8205280053716,163.75132940997366],[171.8205280053716,163.75132940997366]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[80.43456587248531,146.56969661159826],[172.64760001130034,164.72486659520752],[172.64760001130034,164.72486659520752]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[83.04889176968875,146.34597959060318],[173.30170482167705,165.61901871326904],[173.30170482167705,165.61901871326904]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.43326668424646,146.17126547853735],[173.81253471130574,166.43452296328823],[173.81253471130574,166.43452296328823]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[87.59256983033822,146.03668034701371],[174.20603531105112,167.17304813479808],[174.20603531105112,167.17304813479808]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[89.53380628138467,145.9345574790187],[174.50462260185213,167.836990104014],[174.50462260185213,167.836990104014]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.26561476174996,145.85834892439436],[174.72744150919286,168.42930348868117],[174.72744150919286,168.42930348868117]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[92.79786860590211,145.8025286847221],[174.89064897156487,168.95336516806938],[174.89064897156487,168.95336516806938]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[94.14135733109363,145.76249203264803],[175.00770831106672,169.4128653744158],[175.00770831106672,169.4128653744158]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[95.30753676909862,145.73445436416338],[175.08968496955248,169.81172223294573],[175.08968496955248,169.81172223294573]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[96.30833652897755,145.71535209549262],[175.14553626674518,170.1540159102598],[175.14553626674518,170.1540159102598]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.1560145853127,145.70274741992375],[175.18238987263604,170.44393888058158],[175.18238987263604,170.44393888058158]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.8630499083038,145.69473820474883],[175.20580725120632,170.6857592030873],[175.20580725120632,170.6857592030873]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.44206520410059,145.68987390647064],[175.2200295079158,170.88379409754347],[175.2200295079158,170.88379409754347]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.9057729671806,145.68707808794161],[175.22820393443305,171.04239149313332],[175.22820393443305,171.04239149313332]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.26693912845845,145.68557791140813],[175.23259015718264,171.16591759537795],[175.23259015718264,171.16591759537795]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.53835959147887,145.68484083693517],[175.2347452187699,171.25874886104174],[175.2347452187699,171.25874886104174]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.7328458708514,145.6845186598911],[175.23568720142907,171.32526708618758],[175.23568720142907,171.32526708618758]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.86321687378347,145.68439896056552],[175.2360371788439,171.36985659529591],[175.2360371788439,171.36985659529591]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.94229459249615,145.68436400280396],[175.23613938850093,171.3969027679839],[175.23613938850093,171.3969027679839]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.98290209995109,145.6843570984625],[175.2361595754449,171.41079135350373],[175.2361595754449,171.41079135350373]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99786276247576,145.68435644436695],[175.23616148789233,171.41590820144467],[175.23616148789233,171.41590820144467]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"face_0_slot_1","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[100],"h":1},{"t":45,"s":[100],"h":1},{"t":46,"s":[100],"h":1},{"t":47,"s":[100],"h":1},{"t":48,"s":[100],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[100],"h":1},{"t":59,"s":[100],"h":1},{"t":60,"s":[100],"h":1},{"t":61,"s":[100],"h":1},{"t":62,"s":[100],"h":1},{"t":63,"s":[100],"h":1},{"t":64,"s":[100],"h":1},{"t":65,"s":[100],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014843,171.41663917972903],[25.254606555010795,168.4823104778698]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331856,171.41663917972903],[27.051928158630545,165.11883425295738]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.280660659196656,171.41663917972903],[30.329202572241016,161.70386936171468]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513414,171.41663917972903],[35.23167595892863,158.32327808225233]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.545421763770435,171.41663917972903],[41.867430278900926,155.08151817465492]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112144,171.41663917972903],[50.290594742135035,152.10096254861588]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[60.48276971388455,149.51968659541475]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.021711443009764,171.41663917972903],[72.33361100630755,147.4873228087879]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530236,171.41663917972903],[85.62202576908322,146.15861054812373]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.76383848175135,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.15046474746714,166.49908237253814],[114.37797423091669,146.15861054812373],[114.37797423091669,146.15861054812373]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.035359501747664,161.95417685080264],[127.6663889936925,147.4873228087879],[127.6663889936925,147.4873228087879]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.97759368381941,157.9009504134384],[139.51723028611542,149.51968659541475],[139.51723028611542,149.51968659541475]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.524739673823944,154.41502126880033],[149.7094052578649,152.10096254861585],[149.7094052578649,152.10096254861585]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[52.23930132816764,151.53412935182934],[158.13256972109895,155.08151817465486],[158.13256972109895,155.08151817465486]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[61.71757321027329,149.26456770823847],[164.7683240410713,158.3232780822523],[164.7683240410713,158.3232780822523]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.60176086831838,147.5878230578733],[169.67079742775897,161.70386936171468],[169.67079742775897,161.70386936171468]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.58645024373618,146.4669291932127],[172.94807184136943,165.11883425295736],[172.94807184136943,165.11883425295736]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.42059682997896,145.85220900074034],[174.7453934449892,168.48231047786976],[174.7453934449892,168.48231047786976]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.9061668000921,145.6862229301544],[174.3461075040085,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[109.89444817484419,145.9078517026294],[167.00604580866076,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.28089606093508,146.4555207003181],[162.02428061713266,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.9992085169768,147.26962670455904],[158.59688923713986,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[133.01516676125698,148.29425928080326],[156.25574276290297,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.32062633606557,149.47832409788663],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[144.9279202573607,150.77617838683688],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.86483302362365,152.14788350454043],[153.29873006042072,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[154.1702249825266,153.55916933063904],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[157.89032775993167,154.98119240867067],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[161.07569026230578,156.39015609296857],[153.90410328496438,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[163.7787279457848,157.7668476672345],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[166.05181248302944,159.09613519733574],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.94583184319973,160.36645616899267],[156.46115967472622,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.5091497314232,161.56932089599852],[157.58326505555533,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.78689635032543,162.6988462564911],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[171.8205280053716,163.75132940997366],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.64760001130034,164.72486659520752],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.30170482167705,165.61901871326904],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.81253471130574,166.43452296328823],[164.04759992204185,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.20603531105112,167.17304813479808],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.50462260185213,167.836990104014],[166.61675437660261,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.72744150919286,168.42930348868117],[167.821185092542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.89064897156487,168.95336516806938],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.00770831106672,169.4128653744158],[169.99796493107303,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.08968496955248,169.81172223294573],[170.9492051502293,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.14553626674518,170.1540159102598],[171.7996075308126,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.18238987263604,170.44393888058158],[172.5457254570127,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.20580725120632,170.6857592030873],[173.18694334757177,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2200295079158,170.88379409754347],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.22820393443305,171.04239149313332],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23259015718264,171.16591759537795],[174.5136772126083,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2347452187699,171.25874886104174],[174.7787355115039,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23568720142907,171.32526708618758],[174.97042363235644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2360371788439,171.36985659529591],[175.0997505080749,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23613938850093,171.3969027679839],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2361595754449,171.41079135350373],[175.21906944484536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616148789233,171.41590820144467],[175.23402437178987,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"face_1_slot_1","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[100],"h":1},{"t":32,"s":[100],"h":1},{"t":33,"s":[100],"h":1},{"t":34,"s":[100],"h":1},{"t":35,"s":[100],"h":1},{"t":36,"s":[100],"h":1},{"t":37,"s":[100],"h":1},{"t":38,"s":[100],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[100],"h":1},{"t":50,"s":[100],"h":1},{"t":51,"s":[100],"h":1},{"t":52,"s":[100],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.7659756282101,171.41663917972903],[24.763838512107654,171.4159082014447]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515457,171.41663917972903],[24.76384042455507,171.41079135350375]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[24.763860611499055,171.3969027679839]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192508,171.41663917972903],[24.7639628211561,171.36985659529594]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.02957636764353,171.41663917972903],[24.76431279857094,171.32526708618758]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496132,171.41663917972903],[24.765254781230098,171.25874886104174]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391742,171.41663917972903],[24.767409842817358,171.16591759537795]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087367,171.41663917972903],[24.771796065566974,171.04239149313335]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.27467983390227,171.41663917972903],[24.779970492084203,170.8837940975435]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.813056652428145,171.41663917972903],[24.794192748793662,170.68575920308734]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987264,171.41663917972903],[24.817610127363963,170.44393888058158]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187406,171.41663917972903],[24.854463733254804,170.1540159102598]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.050794849770682,171.41663917972903],[24.910315030447507,169.81172223294573]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926943,171.41663917972903],[24.992291688933264,169.41286537441582]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[25.109351028435142,168.95336516806938]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.17881490745795,171.41663917972903],[25.272558490807143,168.42930348868117]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339733,171.41663917972903],[25.495377398147852,167.83699010401403]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[25.793964688948876,167.17304813479808]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795812,171.41663917972903],[26.18746528869424,166.43452296328823]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312886,171.41663917972903],[26.698295178322923,165.61901871326907]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[27.352399988699673,164.72486659520754]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[28.179471994628386,163.75132940997366]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.20571106244855,171.41663917972903],[29.213103649674565,162.69884625649112]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444464,171.41663917972903],[30.49085026857678,161.56932089599854]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527375,171.41663917972903],[32.054168156800245,160.36645616899273]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009054,171.41663917972903],[33.948187516970535,159.09613519733577]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.4085587911411,171.41663917972903],[36.22127205421518,157.76684766723452]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.0958967150356,171.41663917972903],[38.92430973769417,156.39015609296857]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[42.10967224006833,154.9811924086707]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[45.82977501747337,153.55916933063907]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.701269939579305,171.41663917972903],[50.13516697637628,152.14788350454046]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[55.07207974263926,150.77617838683688]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042787,171.41663917972903],[60.67937366393438,149.47832409788663]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709705,171.41663917972903],[66.984833238743,148.29425928080326]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.40311076286019,171.41663917972903],[74.00079148302312,147.26962670455904]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.97571938286738,171.41663917972903],[81.71910393906488,146.4555207003181]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.99395419133931,171.41663917972903],[90.10555182515573,145.9078517026294]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.65389249599157,171.41663917972903],[99.09383319990782,145.6862229301544]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.254606555010795,168.4823104778698],[108.5794031700209,145.85220900074034],[108.5794031700209,145.85220900074034]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.051928158630545,165.11883425295738],[118.41354975626373,146.46692919321268],[118.41354975626373,146.46692919321268]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.329202572241016,161.70386936171468],[128.3982391316816,147.58782305787327],[128.3982391316816,147.58782305787327]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.23167595892863,158.32327808225233],[138.2824267897266,149.26456770823847],[138.2824267897266,149.26456770823847]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[41.867430278900926,155.08151817465492],[147.76069867183222,151.5341293518293],[147.76069867183222,151.5341293518293]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.290594742135035,152.10096254861588],[156.47526032617597,154.4150212688003],[156.47526032617597,154.4150212688003]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.48276971388455,149.51968659541475],[164.0224063161806,157.90095041343838],[164.0224063161806,157.90095041343838]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[72.33361100630755,147.4873228087879],[169.96464049825235,161.95417685080264],[169.96464049825235,161.95417685080264]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.62202576908322,146.15861054812373],[173.84953525253286,166.4990823725381],[173.84953525253286,166.4990823725381]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.37797423091669,146.15861054812373],[164.15776704046982,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[127.6663889936925,147.4873228087879],[157.9782885569902,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.51723028611542,149.51968659541475],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.7094052578649,152.10096254861585],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[158.13256972109895,155.08151817465486],[153.45457823622954,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.7683240410713,158.3232780822523],[154.92905150486584,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.67079742775897,161.70386936171468],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.94807184136943,165.11883425295736],[161.9568687706681,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.7453934449892,168.48231047786976],[167.93271906985146,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"face_2_slot_1","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[100],"h":1},{"t":40,"s":[100],"h":1},{"t":41,"s":[100],"h":1},{"t":42,"s":[100],"h":1},{"t":43,"s":[100],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[100],"h":1},{"t":67,"s":[100],"h":1},{"t":68,"s":[100],"h":1},{"t":69,"s":[100],"h":1},{"t":70,"s":[100],"h":1},{"t":71,"s":[100],"h":1},{"t":72,"s":[100],"h":1},{"t":73,"s":[100],"h":1},{"t":74,"s":[100],"h":1},{"t":75,"s":[100],"h":1},{"t":76,"s":[100],"h":1},{"t":77,"s":[100],"h":1},{"t":78,"s":[100],"h":1},{"t":79,"s":[100],"h":1},{"t":80,"s":[100],"h":1},{"t":81,"s":[100],"h":1},{"t":82,"s":[100],"h":1},{"t":83,"s":[100],"h":1},{"t":84,"s":[100],"h":1},{"t":85,"s":[100],"h":1},{"t":86,"s":[100],"h":1},{"t":87,"s":[100],"h":1},{"t":88,"s":[100],"h":1},{"t":89,"s":[100],"h":1},{"t":90,"s":[100],"h":1},{"t":91,"s":[100],"h":1},{"t":92,"s":[100],"h":1},{"t":93,"s":[100],"h":1},{"t":94,"s":[100],"h":1},{"t":95,"s":[100],"h":1},{"t":96,"s":[100],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838512107654,171.4159082014447],[100.00213723752422,145.68435644436695],[100.00213723752422,145.68435644436695]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76384042455507,171.41079135350375],[100.01709790004887,145.68435709846247],[100.01709790004887,145.68435709846247]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763860611499055,171.3969027679839],[100.05770540750385,145.68436400280396],[100.05770540750385,145.68436400280396]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.7639628211561,171.36985659529594],[100.1367831262165,145.68439896056552],[100.1367831262165,145.68439896056552]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76431279857094,171.32526708618758],[100.26715412914858,145.6845186598911],[100.26715412914858,145.6845186598911]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.765254781230098,171.25874886104174],[100.46164040852113,145.68484083693517],[100.46164040852113,145.68484083693517]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.767409842817358,171.16591759537795],[100.73306087154158,145.68557791140813],[100.73306087154158,145.68557791140813]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.771796065566974,171.04239149313335],[101.09422703281935,145.68707808794161],[101.09422703281935,145.68707808794161]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.779970492084203,170.8837940975435],[101.55793479589938,145.68987390647064],[101.55793479589938,145.68987390647064]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.794192748793662,170.68575920308734],[102.13695009169614,145.69473820474883],[102.13695009169614,145.69473820474883]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.817610127363963,170.44393888058158],[102.84398541468727,145.70274741992375],[102.84398541468727,145.70274741992375]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.854463733254804,170.1540159102598],[103.69166347102245,145.71535209549262],[103.69166347102245,145.71535209549262]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.910315030447507,169.81172223294573],[104.69246323090135,145.73445436416338],[104.69246323090135,145.73445436416338]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.992291688933264,169.41286537441582],[105.85864266890633,145.76249203264803],[105.85864266890633,145.76249203264803]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.109351028435142,168.95336516806938],[107.20213139409789,145.8025286847221],[107.20213139409789,145.8025286847221]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.272558490807143,168.42930348868117],[108.73438523824998,145.85834892439436],[108.73438523824998,145.85834892439436]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.495377398147852,167.83699010401403],[110.46619371861527,145.9345574790187],[110.46619371861527,145.9345574790187]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.793964688948876,167.17304813479808],[112.40743016966178,146.03668034701371],[112.40743016966178,146.03668034701371]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.18746528869424,166.43452296328823],[114.56673331575352,146.17126547853735],[114.56673331575352,146.17126547853735]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.698295178322923,165.61901871326907],[116.9511082303112,146.34597959060318],[116.9511082303112,146.34597959060318]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.352399988699673,164.72486659520754],[119.56543412751468,146.56969661159826],[119.56543412751468,146.56969661159826]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[28.179471994628386,163.75132940997366],[122.41186643342385,146.85257189760665],[122.41186643342385,146.85257189760665]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.213103649674565,162.69884625649112],[125.48912130867366,147.20609474441147],[125.48912130867366,147.20609474441147]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.49085026857678,161.56932089599854],[128.79163252777767,147.6431098261423],[128.79163252777767,147.6431098261423]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.054168156800245,160.36645616899273],[132.30857370939827,148.17779603433607],[132.30857370939827,148.17779603433607]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[33.948187516970535,159.09613519733577],[136.02274375594828,148.82558880736312],[136.02274375594828,148.82558880736312]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[36.22127205421518,157.76684766723452],[139.9093204855403,149.60302950658289],[139.9093204855403,149.60302950658289]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[38.92430973769417,156.39015609296857],[143.93449736804646,150.52752284250107],[143.93449736804646,150.52752284250107]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[42.10967224006833,154.9811924086707],[148.05403158786663,151.61698098210726],[148.05403158786663,151.61698098210726]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[45.82977501747337,153.55916933063907],[152.21174892054898,152.88933106722158],[152.21174892054898,152.88933106722158]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.13516697637628,152.14788350454046],[156.33807262878386,154.36186184207872],[156.33807262878386,154.36186184207872]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[55.07207974263926,150.77617838683688],[160.34867008765178,156.0503854539823],[160.34867008765178,156.0503854539823]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.67937366393438,149.47832409788663],[164.14334216845504,157.96819292461282],[164.14334216845504,157.96819292461282]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[66.984833238743,148.29425928080326],[167.60531609072166,160.12478711212304],[167.60531609072166,160.12478711212304]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.00079148302312,147.26962670455904],[170.60114132569498,162.52438615639866],[170.60114132569498,162.52438615639866]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.71910393906488,146.4555207003181],[172.98142804309384,165.16420448884634],[172.98142804309384,165.16420448884634]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[90.10555182515573,145.9078517026294],[174.58270506836902,168.0325385968404],[174.58270506836902,168.0325385968404]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.09383319990782,145.6862229301544],[175.2307042485341,171.10671188088455],[175.2307042485341,171.10671188088455]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.5794031700209,145.85220900074034],[167.93271906985157,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.41354975626373,146.46692919321268],[161.95686877066814,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.3982391316816,147.58782305787327],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[138.2824267897266,149.26456770823847],[154.92905150486587,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[147.76069867183222,151.5341293518293],[153.45457823622957,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.47526032617597,154.4150212688003],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.0224063161806,157.90095041343838],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.96464049825235,161.95417685080264],[157.97828855699024,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.84953525253286,166.4990823725381],[164.15776704046976,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.653892495991485,171.41663917972903],[24.76929575146589,171.10671188088457]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.993954191339256,171.41663917972903],[25.417294931630963,168.03253859684042]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.975719382867354,171.41663917972903],[27.01857195690613,165.16420448884637]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.403110762860145,171.41663917972903],[29.398858674304975,162.5243861563987]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709703,171.41663917972903],[32.394683909278314,160.12478711212307]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042786,171.41663917972903],[35.856657831544936,157.96819292461282]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[39.65132991234821,156.0503854539823]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.70126993957929,171.41663917972903],[43.661927371216095,154.36186184207872]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[47.78825107945101,152.88933106722158]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[51.94596841213336,151.61698098210726]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.095896715035614,171.41663917972903],[56.06550263195349,150.52752284250107]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.40855879114112,171.41663917972903],[60.09067951445965,149.6030295065829]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009055,171.41663917972903],[63.97725624405165,148.82558880736312]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527377,171.41663917972903],[67.69142629060164,148.1777960343361]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444467,171.41663917972903],[71.20836747222228,147.6431098261423]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.205711062448565,171.41663917972903],[74.5108786913263,147.20609474441147]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[77.58813356657613,146.85257189760665]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[80.43456587248531,146.56969661159826]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312914,171.41663917972903],[83.04889176968875,146.34597959060318]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795813,171.41663917972903],[85.43326668424646,146.17126547853735]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[87.59256983033822,146.03668034701371]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339737,171.41663917972903],[89.53380628138467,145.9345574790187]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.178814907457976,171.41663917972903],[91.26561476174996,145.85834892439436]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[92.79786860590211,145.8025286847221]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926972,171.41663917972903],[94.14135733109363,145.76249203264803]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.05079484977071,171.41663917972903],[95.30753676909862,145.73445436416338]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187392,171.41663917972903],[96.30833652897755,145.71535209549262]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987292,171.41663917972903],[97.1560145853127,145.70274741992375]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.81305665242823,171.41663917972903],[97.8630499083038,145.69473820474883]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.274679833902283,171.41663917972903],[98.44206520410059,145.68987390647064]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087395,171.41663917972903],[98.9057729671806,145.68707808794161]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391714,171.41663917972903],[99.26693912845845,145.68557791140813]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496118,171.41663917972903],[99.53835959147887,145.68484083693517]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.029576367643557,171.41663917972903],[99.7328458708514,145.6845186598911]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192511,171.41663917972903],[99.86321687378347,145.68439896056552]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[99.94229459249615,145.68436400280396]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515464,171.41663917972903],[99.98290209995109,145.6843570984625]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.765975628210114,171.41663917972903],[99.99786276247576,145.68435644436695]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751364,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"face_3_slot_1","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":1,"s":[100],"h":1},{"t":2,"s":[100],"h":1},{"t":3,"s":[100],"h":1},{"t":4,"s":[100],"h":1},{"t":5,"s":[100],"h":1},{"t":6,"s":[100],"h":1},{"t":7,"s":[100],"h":1},{"t":8,"s":[100],"h":1},{"t":9,"s":[100],"h":1},{"t":10,"s":[100],"h":1},{"t":11,"s":[100],"h":1},{"t":12,"s":[100],"h":1},{"t":13,"s":[100],"h":1},{"t":14,"s":[100],"h":1},{"t":15,"s":[100],"h":1},{"t":16,"s":[100],"h":1},{"t":17,"s":[100],"h":1},{"t":18,"s":[100],"h":1},{"t":19,"s":[100],"h":1},{"t":20,"s":[100],"h":1},{"t":21,"s":[100],"h":1},{"t":22,"s":[100],"h":1},{"t":23,"s":[100],"h":1},{"t":24,"s":[100],"h":1},{"t":25,"s":[100],"h":1},{"t":26,"s":[100],"h":1},{"t":27,"s":[100],"h":1},{"t":28,"s":[100],"h":1},{"t":29,"s":[100],"h":1},{"t":30,"s":[100],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[100],"h":1},{"t":54,"s":[100],"h":1},{"t":55,"s":[100],"h":1},{"t":56,"s":[100],"h":1},{"t":57,"s":[100],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[100],"h":1},{"t":98,"s":[100],"h":1},{"t":99,"s":[100],"h":1},{"t":100,"s":[100],"h":1},{"t":101,"s":[100],"h":1},{"t":102,"s":[100],"h":1},{"t":103,"s":[100],"h":1},{"t":104,"s":[100],"h":1},{"t":105,"s":[100],"h":1},{"t":106,"s":[100],"h":1},{"t":107,"s":[100],"h":1},{"t":108,"s":[100],"h":1},{"t":109,"s":[100],"h":1},{"t":110,"s":[100],"h":1},{"t":111,"s":[100],"h":1},{"t":112,"s":[100],"h":1},{"t":113,"s":[100],"h":1},{"t":114,"s":[100],"h":1},{"t":115,"s":[100],"h":1},{"t":116,"s":[100],"h":1},{"t":117,"s":[100],"h":1},{"t":118,"s":[100],"h":1},{"t":119,"s":[100],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.00213723752422,145.68435644436695],[175.2340243717899,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.01709790004887,145.68435709846247],[175.21906944484545,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.05770540750385,145.68436400280396],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.1367831262165,145.68439896056552],[175.09975050807492,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.26715412914858,145.6845186598911],[174.97042363235647,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.46164040852113,145.68484083693517],[174.77873551150387,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.73306087154158,145.68557791140813],[174.51367721260826,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.09422703281935,145.68707808794161],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.55793479589938,145.68987390647064],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.13695009169614,145.69473820474883],[173.18694334757186,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.84398541468727,145.70274741992375],[172.54572545701274,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[103.69166347102245,145.71535209549262],[171.79960753081258,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[104.69246323090135,145.73445436416338],[170.94920515022932,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[105.85864266890633,145.76249203264803],[169.99796493107306,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[107.20213139409789,145.8025286847221],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.73438523824998,145.85834892439436],[167.82118509254207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[110.46619371861527,145.9345574790187],[166.61675437660267,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[112.40743016966178,146.03668034701371],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.56673331575352,146.17126547853735],[164.04759992204188,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[116.9511082303112,146.34597959060318],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[119.56543412751468,146.56969661159826],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[122.41186643342385,146.85257189760665],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.48912130867366,147.20609474441147],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.79163252777767,147.6431098261423],[157.58326505555536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[132.30857370939827,148.17779603433607],[156.46115967472625,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[136.02274375594828,148.82558880736312],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.9093204855403,149.60302950658289],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[143.93449736804646,150.52752284250107],[153.9041032849644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[148.05403158786663,151.61698098210726],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[152.21174892054898,152.88933106722158],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.33807262878386,154.36186184207872],[153.2987300604207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[160.34867008765178,156.0503854539823],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.14334216845504,157.96819292461282],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.60531609072166,160.12478711212304],[156.25574276290294,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.60114132569498,162.52438615639866],[158.5968892371398,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.98142804309384,165.16420448884634],[162.0242806171326,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.58270506836902,168.0325385968404],[167.0060458086607,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2307042485341,171.10671188088455],[174.34610750400844,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530165,171.41663917972903],[26.15046474746714,166.49908237253814]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.02171144300978,171.41663917972903],[30.035359501747664,161.95417685080264]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[35.97759368381941,157.9009504134384]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112142,171.41663917972903],[43.524739673823944,154.41502126880033]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.54542176377046,171.41663917972903],[52.23930132816764,151.53412935182934]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513416,171.41663917972903],[61.71757321027329,149.26456770823847]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.28066065919667,171.41663917972903],[71.60176086831838,147.5878230578733]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331905,171.41663917972903],[81.58645024373618,146.4669291932127]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014854,171.41663917972903],[91.42059682997896,145.85220900074034]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76929575146589,171.10671188088457],[100.9061668000921,145.6862229301544],[100.9061668000921,145.6862229301544]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.417294931630963,168.03253859684042],[109.89444817484419,145.9078517026294],[109.89444817484419,145.9078517026294]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.01857195690613,165.16420448884637],[118.28089606093508,146.4555207003181],[118.28089606093508,146.4555207003181]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.398858674304975,162.5243861563987],[125.9992085169768,147.26962670455904],[125.9992085169768,147.26962670455904]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.394683909278314,160.12478711212307],[133.01516676125698,148.29425928080326],[133.01516676125698,148.29425928080326]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.856657831544936,157.96819292461282],[139.32062633606557,149.47832409788663],[139.32062633606557,149.47832409788663]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[39.65132991234821,156.0503854539823],[144.9279202573607,150.77617838683688],[144.9279202573607,150.77617838683688]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.661927371216095,154.36186184207872],[149.86483302362365,152.14788350454043],[149.86483302362365,152.14788350454043]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[47.78825107945101,152.88933106722158],[154.1702249825266,153.55916933063904],[154.1702249825266,153.55916933063904]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[51.94596841213336,151.61698098210726],[157.89032775993167,154.98119240867067],[157.89032775993167,154.98119240867067]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[56.06550263195349,150.52752284250107],[161.07569026230578,156.39015609296857],[161.07569026230578,156.39015609296857]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.09067951445965,149.6030295065829],[163.7787279457848,157.7668476672345],[163.7787279457848,157.7668476672345]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[63.97725624405165,148.82558880736312],[166.05181248302944,159.09613519733574],[166.05181248302944,159.09613519733574]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[67.69142629060164,148.1777960343361],[167.94583184319973,160.36645616899267],[167.94583184319973,160.36645616899267]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.20836747222228,147.6431098261423],[169.5091497314232,161.56932089599852],[169.5091497314232,161.56932089599852]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.5108786913263,147.20609474441147],[170.78689635032543,162.6988462564911],[170.78689635032543,162.6988462564911]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[77.58813356657613,146.85257189760665],[171.8205280053716,163.75132940997366],[171.8205280053716,163.75132940997366]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[80.43456587248531,146.56969661159826],[172.64760001130034,164.72486659520752],[172.64760001130034,164.72486659520752]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[83.04889176968875,146.34597959060318],[173.30170482167705,165.61901871326904],[173.30170482167705,165.61901871326904]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.43326668424646,146.17126547853735],[173.81253471130574,166.43452296328823],[173.81253471130574,166.43452296328823]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[87.59256983033822,146.03668034701371],[174.20603531105112,167.17304813479808],[174.20603531105112,167.17304813479808]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[89.53380628138467,145.9345574790187],[174.50462260185213,167.836990104014],[174.50462260185213,167.836990104014]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.26561476174996,145.85834892439436],[174.72744150919286,168.42930348868117],[174.72744150919286,168.42930348868117]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[92.79786860590211,145.8025286847221],[174.89064897156487,168.95336516806938],[174.89064897156487,168.95336516806938]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[94.14135733109363,145.76249203264803],[175.00770831106672,169.4128653744158],[175.00770831106672,169.4128653744158]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[95.30753676909862,145.73445436416338],[175.08968496955248,169.81172223294573],[175.08968496955248,169.81172223294573]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[96.30833652897755,145.71535209549262],[175.14553626674518,170.1540159102598],[175.14553626674518,170.1540159102598]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.1560145853127,145.70274741992375],[175.18238987263604,170.44393888058158],[175.18238987263604,170.44393888058158]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.8630499083038,145.69473820474883],[175.20580725120632,170.6857592030873],[175.20580725120632,170.6857592030873]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.44206520410059,145.68987390647064],[175.2200295079158,170.88379409754347],[175.2200295079158,170.88379409754347]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.9057729671806,145.68707808794161],[175.22820393443305,171.04239149313332],[175.22820393443305,171.04239149313332]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.26693912845845,145.68557791140813],[175.23259015718264,171.16591759537795],[175.23259015718264,171.16591759537795]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.53835959147887,145.68484083693517],[175.2347452187699,171.25874886104174],[175.2347452187699,171.25874886104174]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.7328458708514,145.6845186598911],[175.23568720142907,171.32526708618758],[175.23568720142907,171.32526708618758]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.86321687378347,145.68439896056552],[175.2360371788439,171.36985659529591],[175.2360371788439,171.36985659529591]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.94229459249615,145.68436400280396],[175.23613938850093,171.3969027679839],[175.23613938850093,171.3969027679839]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.98290209995109,145.6843570984625],[175.2361595754449,171.41079135350373],[175.2361595754449,171.41079135350373]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99786276247576,145.68435644436695],[175.23616148789233,171.41590820144467],[175.23616148789233,171.41590820144467]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"face_0_slot_0","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[100],"h":1},{"t":50,"s":[100],"h":1},{"t":51,"s":[100],"h":1},{"t":52,"s":[100],"h":1},{"t":53,"s":[100],"h":1},{"t":54,"s":[100],"h":1},{"t":55,"s":[100],"h":1},{"t":56,"s":[100],"h":1},{"t":57,"s":[100],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014843,171.41663917972903],[25.254606555010795,168.4823104778698]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331856,171.41663917972903],[27.051928158630545,165.11883425295738]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.280660659196656,171.41663917972903],[30.329202572241016,161.70386936171468]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513414,171.41663917972903],[35.23167595892863,158.32327808225233]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.545421763770435,171.41663917972903],[41.867430278900926,155.08151817465492]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112144,171.41663917972903],[50.290594742135035,152.10096254861588]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[60.48276971388455,149.51968659541475]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.021711443009764,171.41663917972903],[72.33361100630755,147.4873228087879]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530236,171.41663917972903],[85.62202576908322,146.15861054812373]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.76383848175135,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.15046474746714,166.49908237253814],[114.37797423091669,146.15861054812373],[114.37797423091669,146.15861054812373]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.035359501747664,161.95417685080264],[127.6663889936925,147.4873228087879],[127.6663889936925,147.4873228087879]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.97759368381941,157.9009504134384],[139.51723028611542,149.51968659541475],[139.51723028611542,149.51968659541475]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.524739673823944,154.41502126880033],[149.7094052578649,152.10096254861585],[149.7094052578649,152.10096254861585]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[52.23930132816764,151.53412935182934],[158.13256972109895,155.08151817465486],[158.13256972109895,155.08151817465486]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[61.71757321027329,149.26456770823847],[164.7683240410713,158.3232780822523],[164.7683240410713,158.3232780822523]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.60176086831838,147.5878230578733],[169.67079742775897,161.70386936171468],[169.67079742775897,161.70386936171468]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.58645024373618,146.4669291932127],[172.94807184136943,165.11883425295736],[172.94807184136943,165.11883425295736]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.42059682997896,145.85220900074034],[174.7453934449892,168.48231047786976],[174.7453934449892,168.48231047786976]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.9061668000921,145.6862229301544],[174.3461075040085,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[109.89444817484419,145.9078517026294],[167.00604580866076,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.28089606093508,146.4555207003181],[162.02428061713266,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.9992085169768,147.26962670455904],[158.59688923713986,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[133.01516676125698,148.29425928080326],[156.25574276290297,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.32062633606557,149.47832409788663],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[144.9279202573607,150.77617838683688],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.86483302362365,152.14788350454043],[153.29873006042072,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[154.1702249825266,153.55916933063904],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[157.89032775993167,154.98119240867067],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[161.07569026230578,156.39015609296857],[153.90410328496438,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[163.7787279457848,157.7668476672345],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[166.05181248302944,159.09613519733574],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.94583184319973,160.36645616899267],[156.46115967472622,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.5091497314232,161.56932089599852],[157.58326505555533,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.78689635032543,162.6988462564911],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[171.8205280053716,163.75132940997366],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.64760001130034,164.72486659520752],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.30170482167705,165.61901871326904],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.81253471130574,166.43452296328823],[164.04759992204185,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.20603531105112,167.17304813479808],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.50462260185213,167.836990104014],[166.61675437660261,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.72744150919286,168.42930348868117],[167.821185092542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.89064897156487,168.95336516806938],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.00770831106672,169.4128653744158],[169.99796493107303,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.08968496955248,169.81172223294573],[170.9492051502293,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.14553626674518,170.1540159102598],[171.7996075308126,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.18238987263604,170.44393888058158],[172.5457254570127,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.20580725120632,170.6857592030873],[173.18694334757177,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2200295079158,170.88379409754347],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.22820393443305,171.04239149313332],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23259015718264,171.16591759537795],[174.5136772126083,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2347452187699,171.25874886104174],[174.7787355115039,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23568720142907,171.32526708618758],[174.97042363235644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2360371788439,171.36985659529591],[175.0997505080749,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23613938850093,171.3969027679839],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2361595754449,171.41079135350373],[175.21906944484536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616148789233,171.41590820144467],[175.23402437178987,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"face_1_slot_0","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[100],"h":1},{"t":40,"s":[100],"h":1},{"t":41,"s":[100],"h":1},{"t":42,"s":[100],"h":1},{"t":43,"s":[100],"h":1},{"t":44,"s":[100],"h":1},{"t":45,"s":[100],"h":1},{"t":46,"s":[100],"h":1},{"t":47,"s":[100],"h":1},{"t":48,"s":[100],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.7659756282101,171.41663917972903],[24.763838512107654,171.4159082014447]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515457,171.41663917972903],[24.76384042455507,171.41079135350375]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[24.763860611499055,171.3969027679839]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192508,171.41663917972903],[24.7639628211561,171.36985659529594]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.02957636764353,171.41663917972903],[24.76431279857094,171.32526708618758]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496132,171.41663917972903],[24.765254781230098,171.25874886104174]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391742,171.41663917972903],[24.767409842817358,171.16591759537795]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087367,171.41663917972903],[24.771796065566974,171.04239149313335]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.27467983390227,171.41663917972903],[24.779970492084203,170.8837940975435]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.813056652428145,171.41663917972903],[24.794192748793662,170.68575920308734]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987264,171.41663917972903],[24.817610127363963,170.44393888058158]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187406,171.41663917972903],[24.854463733254804,170.1540159102598]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.050794849770682,171.41663917972903],[24.910315030447507,169.81172223294573]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926943,171.41663917972903],[24.992291688933264,169.41286537441582]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[25.109351028435142,168.95336516806938]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.17881490745795,171.41663917972903],[25.272558490807143,168.42930348868117]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339733,171.41663917972903],[25.495377398147852,167.83699010401403]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[25.793964688948876,167.17304813479808]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795812,171.41663917972903],[26.18746528869424,166.43452296328823]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312886,171.41663917972903],[26.698295178322923,165.61901871326907]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[27.352399988699673,164.72486659520754]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[28.179471994628386,163.75132940997366]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.20571106244855,171.41663917972903],[29.213103649674565,162.69884625649112]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444464,171.41663917972903],[30.49085026857678,161.56932089599854]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527375,171.41663917972903],[32.054168156800245,160.36645616899273]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009054,171.41663917972903],[33.948187516970535,159.09613519733577]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.4085587911411,171.41663917972903],[36.22127205421518,157.76684766723452]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.0958967150356,171.41663917972903],[38.92430973769417,156.39015609296857]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[42.10967224006833,154.9811924086707]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[45.82977501747337,153.55916933063907]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.701269939579305,171.41663917972903],[50.13516697637628,152.14788350454046]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[55.07207974263926,150.77617838683688]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042787,171.41663917972903],[60.67937366393438,149.47832409788663]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709705,171.41663917972903],[66.984833238743,148.29425928080326]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.40311076286019,171.41663917972903],[74.00079148302312,147.26962670455904]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.97571938286738,171.41663917972903],[81.71910393906488,146.4555207003181]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.99395419133931,171.41663917972903],[90.10555182515573,145.9078517026294]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.65389249599157,171.41663917972903],[99.09383319990782,145.6862229301544]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.254606555010795,168.4823104778698],[108.5794031700209,145.85220900074034],[108.5794031700209,145.85220900074034]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.051928158630545,165.11883425295738],[118.41354975626373,146.46692919321268],[118.41354975626373,146.46692919321268]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.329202572241016,161.70386936171468],[128.3982391316816,147.58782305787327],[128.3982391316816,147.58782305787327]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.23167595892863,158.32327808225233],[138.2824267897266,149.26456770823847],[138.2824267897266,149.26456770823847]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[41.867430278900926,155.08151817465492],[147.76069867183222,151.5341293518293],[147.76069867183222,151.5341293518293]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.290594742135035,152.10096254861588],[156.47526032617597,154.4150212688003],[156.47526032617597,154.4150212688003]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.48276971388455,149.51968659541475],[164.0224063161806,157.90095041343838],[164.0224063161806,157.90095041343838]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[72.33361100630755,147.4873228087879],[169.96464049825235,161.95417685080264],[169.96464049825235,161.95417685080264]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.62202576908322,146.15861054812373],[173.84953525253286,166.4990823725381],[173.84953525253286,166.4990823725381]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.37797423091669,146.15861054812373],[164.15776704046982,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[127.6663889936925,147.4873228087879],[157.9782885569902,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.51723028611542,149.51968659541475],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[149.7094052578649,152.10096254861585],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[158.13256972109895,155.08151817465486],[153.45457823622954,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.7683240410713,158.3232780822523],[154.92905150486584,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.67079742775897,161.70386936171468],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.94807184136943,165.11883425295736],[161.9568687706681,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.7453934449892,168.48231047786976],[167.93271906985146,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751336,171.41663917972903],[24.763838481751336,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"face_2_slot_0","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":1,"s":[100],"h":1},{"t":2,"s":[100],"h":1},{"t":3,"s":[100],"h":1},{"t":4,"s":[100],"h":1},{"t":5,"s":[100],"h":1},{"t":6,"s":[100],"h":1},{"t":7,"s":[100],"h":1},{"t":8,"s":[100],"h":1},{"t":9,"s":[100],"h":1},{"t":10,"s":[100],"h":1},{"t":11,"s":[100],"h":1},{"t":12,"s":[100],"h":1},{"t":13,"s":[100],"h":1},{"t":14,"s":[100],"h":1},{"t":15,"s":[100],"h":1},{"t":16,"s":[100],"h":1},{"t":17,"s":[100],"h":1},{"t":18,"s":[100],"h":1},{"t":19,"s":[100],"h":1},{"t":20,"s":[100],"h":1},{"t":21,"s":[100],"h":1},{"t":22,"s":[100],"h":1},{"t":23,"s":[100],"h":1},{"t":24,"s":[100],"h":1},{"t":25,"s":[100],"h":1},{"t":26,"s":[100],"h":1},{"t":27,"s":[100],"h":1},{"t":28,"s":[100],"h":1},{"t":29,"s":[100],"h":1},{"t":30,"s":[100],"h":1},{"t":31,"s":[100],"h":1},{"t":32,"s":[100],"h":1},{"t":33,"s":[100],"h":1},{"t":34,"s":[100],"h":1},{"t":35,"s":[100],"h":1},{"t":36,"s":[100],"h":1},{"t":37,"s":[100],"h":1},{"t":38,"s":[100],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[0],"h":1},{"t":59,"s":[0],"h":1},{"t":60,"s":[0],"h":1},{"t":61,"s":[0],"h":1},{"t":62,"s":[0],"h":1},{"t":63,"s":[0],"h":1},{"t":64,"s":[0],"h":1},{"t":65,"s":[0],"h":1},{"t":66,"s":[0],"h":1},{"t":67,"s":[0],"h":1},{"t":68,"s":[0],"h":1},{"t":69,"s":[0],"h":1},{"t":70,"s":[0],"h":1},{"t":71,"s":[0],"h":1},{"t":72,"s":[0],"h":1},{"t":73,"s":[0],"h":1},{"t":74,"s":[0],"h":1},{"t":75,"s":[0],"h":1},{"t":76,"s":[0],"h":1},{"t":77,"s":[0],"h":1},{"t":78,"s":[0],"h":1},{"t":79,"s":[0],"h":1},{"t":80,"s":[0],"h":1},{"t":81,"s":[0],"h":1},{"t":82,"s":[0],"h":1},{"t":83,"s":[0],"h":1},{"t":84,"s":[0],"h":1},{"t":85,"s":[0],"h":1},{"t":86,"s":[0],"h":1},{"t":87,"s":[0],"h":1},{"t":88,"s":[0],"h":1},{"t":89,"s":[0],"h":1},{"t":90,"s":[0],"h":1},{"t":91,"s":[0],"h":1},{"t":92,"s":[0],"h":1},{"t":93,"s":[0],"h":1},{"t":94,"s":[0],"h":1},{"t":95,"s":[0],"h":1},{"t":96,"s":[0],"h":1},{"t":97,"s":[100],"h":1},{"t":98,"s":[100],"h":1},{"t":99,"s":[100],"h":1},{"t":100,"s":[100],"h":1},{"t":101,"s":[100],"h":1},{"t":102,"s":[100],"h":1},{"t":103,"s":[100],"h":1},{"t":104,"s":[100],"h":1},{"t":105,"s":[100],"h":1},{"t":106,"s":[100],"h":1},{"t":107,"s":[100],"h":1},{"t":108,"s":[100],"h":1},{"t":109,"s":[100],"h":1},{"t":110,"s":[100],"h":1},{"t":111,"s":[100],"h":1},{"t":112,"s":[100],"h":1},{"t":113,"s":[100],"h":1},{"t":114,"s":[100],"h":1},{"t":115,"s":[100],"h":1},{"t":116,"s":[100],"h":1},{"t":117,"s":[100],"h":1},{"t":118,"s":[100],"h":1},{"t":119,"s":[100],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838512107654,171.4159082014447],[100.00213723752422,145.68435644436695],[100.00213723752422,145.68435644436695]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76384042455507,171.41079135350375],[100.01709790004887,145.68435709846247],[100.01709790004887,145.68435709846247]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763860611499055,171.3969027679839],[100.05770540750385,145.68436400280396],[100.05770540750385,145.68436400280396]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.7639628211561,171.36985659529594],[100.1367831262165,145.68439896056552],[100.1367831262165,145.68439896056552]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76431279857094,171.32526708618758],[100.26715412914858,145.6845186598911],[100.26715412914858,145.6845186598911]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.765254781230098,171.25874886104174],[100.46164040852113,145.68484083693517],[100.46164040852113,145.68484083693517]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.767409842817358,171.16591759537795],[100.73306087154158,145.68557791140813],[100.73306087154158,145.68557791140813]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.771796065566974,171.04239149313335],[101.09422703281935,145.68707808794161],[101.09422703281935,145.68707808794161]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.779970492084203,170.8837940975435],[101.55793479589938,145.68987390647064],[101.55793479589938,145.68987390647064]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.794192748793662,170.68575920308734],[102.13695009169614,145.69473820474883],[102.13695009169614,145.69473820474883]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.817610127363963,170.44393888058158],[102.84398541468727,145.70274741992375],[102.84398541468727,145.70274741992375]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.854463733254804,170.1540159102598],[103.69166347102245,145.71535209549262],[103.69166347102245,145.71535209549262]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.910315030447507,169.81172223294573],[104.69246323090135,145.73445436416338],[104.69246323090135,145.73445436416338]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.992291688933264,169.41286537441582],[105.85864266890633,145.76249203264803],[105.85864266890633,145.76249203264803]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.109351028435142,168.95336516806938],[107.20213139409789,145.8025286847221],[107.20213139409789,145.8025286847221]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.272558490807143,168.42930348868117],[108.73438523824998,145.85834892439436],[108.73438523824998,145.85834892439436]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.495377398147852,167.83699010401403],[110.46619371861527,145.9345574790187],[110.46619371861527,145.9345574790187]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.793964688948876,167.17304813479808],[112.40743016966178,146.03668034701371],[112.40743016966178,146.03668034701371]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.18746528869424,166.43452296328823],[114.56673331575352,146.17126547853735],[114.56673331575352,146.17126547853735]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[26.698295178322923,165.61901871326907],[116.9511082303112,146.34597959060318],[116.9511082303112,146.34597959060318]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.352399988699673,164.72486659520754],[119.56543412751468,146.56969661159826],[119.56543412751468,146.56969661159826]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[28.179471994628386,163.75132940997366],[122.41186643342385,146.85257189760665],[122.41186643342385,146.85257189760665]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.213103649674565,162.69884625649112],[125.48912130867366,147.20609474441147],[125.48912130867366,147.20609474441147]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[30.49085026857678,161.56932089599854],[128.79163252777767,147.6431098261423],[128.79163252777767,147.6431098261423]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.054168156800245,160.36645616899273],[132.30857370939827,148.17779603433607],[132.30857370939827,148.17779603433607]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[33.948187516970535,159.09613519733577],[136.02274375594828,148.82558880736312],[136.02274375594828,148.82558880736312]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[36.22127205421518,157.76684766723452],[139.9093204855403,149.60302950658289],[139.9093204855403,149.60302950658289]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[38.92430973769417,156.39015609296857],[143.93449736804646,150.52752284250107],[143.93449736804646,150.52752284250107]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[42.10967224006833,154.9811924086707],[148.05403158786663,151.61698098210726],[148.05403158786663,151.61698098210726]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[45.82977501747337,153.55916933063907],[152.21174892054898,152.88933106722158],[152.21174892054898,152.88933106722158]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[50.13516697637628,152.14788350454046],[156.33807262878386,154.36186184207872],[156.33807262878386,154.36186184207872]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[55.07207974263926,150.77617838683688],[160.34867008765178,156.0503854539823],[160.34867008765178,156.0503854539823]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.67937366393438,149.47832409788663],[164.14334216845504,157.96819292461282],[164.14334216845504,157.96819292461282]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[66.984833238743,148.29425928080326],[167.60531609072166,160.12478711212304],[167.60531609072166,160.12478711212304]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.00079148302312,147.26962670455904],[170.60114132569498,162.52438615639866],[170.60114132569498,162.52438615639866]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[81.71910393906488,146.4555207003181],[172.98142804309384,165.16420448884634],[172.98142804309384,165.16420448884634]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[90.10555182515573,145.9078517026294],[174.58270506836902,168.0325385968404],[174.58270506836902,168.0325385968404]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.09383319990782,145.6862229301544],[175.2307042485341,171.10671188088455],[175.2307042485341,171.10671188088455]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.5794031700209,145.85220900074034],[167.93271906985157,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[118.41354975626373,146.46692919321268],[161.95686877066814,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.3982391316816,147.58782305787327],[157.71933934080334,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[138.2824267897266,149.26456770823847],[154.92905150486587,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[147.76069867183222,151.5341293518293],[153.45457823622957,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.47526032617597,154.4150212688003],[153.30788554887857,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.0224063161806,157.90095041343838],[154.6696915862507,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[169.96464049825235,161.95417685080264],[157.97828855699024,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[173.84953525253286,166.4990823725381],[164.15776704046976,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.653892495991485,171.41663917972903],[24.76929575146589,171.10671188088457]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.993954191339256,171.41663917972903],[25.417294931630963,168.03253859684042]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.975719382867354,171.41663917972903],[27.01857195690613,165.16420448884637]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.403110762860145,171.41663917972903],[29.398858674304975,162.5243861563987]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.74425723709703,171.41663917972903],[32.394683909278314,160.12478711212307]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.29032588042786,171.41663917972903],[35.856657831544936,157.96819292461282]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.23230120343495,171.41663917972903],[39.65132991234821,156.0503854539823]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.70126993957929,171.41663917972903],[43.661927371216095,154.36186184207872]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.79098542430458,171.41663917972903],[47.78825107945101,152.88933106722158]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.571199874666725,171.41663917972903],[51.94596841213336,151.61698098210726]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.095896715035614,171.41663917972903],[56.06550263195349,150.52752284250107]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.40855879114112,171.41663917972903],[60.09067951445965,149.6030295065829]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[44.54563205009055,171.41663917972903],[63.97725624405165,148.82558880736312]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[43.53884032527377,171.41663917972903],[67.69142629060164,148.1777960343361]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.41673494444467,171.41663917972903],[71.20836747222228,147.6431098261423]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[41.205711062448565,171.41663917972903],[74.5108786913263,147.20609474441147]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[39.930636022662924,171.41663917972903],[77.58813356657613,146.85257189760665]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.615185446789816,171.41663917972903],[80.43456587248531,146.56969661159826]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[37.281954893312914,171.41663917972903],[83.04889176968875,146.34597959060318]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.95240007795813,171.41663917972903],[85.43326668424646,146.17126547853735]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[34.64665143481096,171.41663917972903],[87.59256983033822,146.03668034701371]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[33.38324562339737,171.41663917972903],[89.53380628138467,145.9345574790187]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.178814907457976,171.41663917972903],[91.26561476174996,145.85834892439436]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[31.047773326877973,171.41663917972903],[92.79786860590211,145.8025286847221]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[30.002035068926972,171.41663917972903],[94.14135733109363,145.76249203264803]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[29.05079484977071,171.41663917972903],[95.30753676909862,145.73445436416338]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[28.200392469187392,171.41663917972903],[96.30833652897755,145.71535209549262]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[27.454274542987292,171.41663917972903],[97.1560145853127,145.70274741992375]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.81305665242823,171.41663917972903],[97.8630499083038,145.69473820474883]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[26.274679833902283,171.41663917972903],[98.44206520410059,145.68987390647064]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.834647452087395,171.41663917972903],[98.9057729671806,145.68707808794161]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.486322787391714,171.41663917972903],[99.26693912845845,145.68557791140813]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.221264488496118,171.41663917972903],[99.53835959147887,145.68484083693517]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[25.029576367643557,171.41663917972903],[99.7328458708514,145.6845186598911]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.90024949192511,171.41663917972903],[99.86321687378347,145.68439896056552]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.82147756784029,171.41663917972903],[99.94229459249615,145.68436400280396]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.78093055515464,171.41663917972903],[99.98290209995109,145.6843570984625]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.765975628210114,171.41663917972903],[99.99786276247576,145.68435644436695]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[24.763838481751364,171.41663917972903],[99.99999999999999,145.68435643398448]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.763838481751336,171.41663917972903],[100,145.68435643398448],[100,145.68435643398448]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"face_3_slot_0","sr":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"h":1},{"t":1,"s":[0],"h":1},{"t":2,"s":[0],"h":1},{"t":3,"s":[0],"h":1},{"t":4,"s":[0],"h":1},{"t":5,"s":[0],"h":1},{"t":6,"s":[0],"h":1},{"t":7,"s":[0],"h":1},{"t":8,"s":[0],"h":1},{"t":9,"s":[0],"h":1},{"t":10,"s":[0],"h":1},{"t":11,"s":[0],"h":1},{"t":12,"s":[0],"h":1},{"t":13,"s":[0],"h":1},{"t":14,"s":[0],"h":1},{"t":15,"s":[0],"h":1},{"t":16,"s":[0],"h":1},{"t":17,"s":[0],"h":1},{"t":18,"s":[0],"h":1},{"t":19,"s":[0],"h":1},{"t":20,"s":[0],"h":1},{"t":21,"s":[0],"h":1},{"t":22,"s":[0],"h":1},{"t":23,"s":[0],"h":1},{"t":24,"s":[0],"h":1},{"t":25,"s":[0],"h":1},{"t":26,"s":[0],"h":1},{"t":27,"s":[0],"h":1},{"t":28,"s":[0],"h":1},{"t":29,"s":[0],"h":1},{"t":30,"s":[0],"h":1},{"t":31,"s":[0],"h":1},{"t":32,"s":[0],"h":1},{"t":33,"s":[0],"h":1},{"t":34,"s":[0],"h":1},{"t":35,"s":[0],"h":1},{"t":36,"s":[0],"h":1},{"t":37,"s":[0],"h":1},{"t":38,"s":[0],"h":1},{"t":39,"s":[0],"h":1},{"t":40,"s":[0],"h":1},{"t":41,"s":[0],"h":1},{"t":42,"s":[0],"h":1},{"t":43,"s":[0],"h":1},{"t":44,"s":[0],"h":1},{"t":45,"s":[0],"h":1},{"t":46,"s":[0],"h":1},{"t":47,"s":[0],"h":1},{"t":48,"s":[0],"h":1},{"t":49,"s":[0],"h":1},{"t":50,"s":[0],"h":1},{"t":51,"s":[0],"h":1},{"t":52,"s":[0],"h":1},{"t":53,"s":[0],"h":1},{"t":54,"s":[0],"h":1},{"t":55,"s":[0],"h":1},{"t":56,"s":[0],"h":1},{"t":57,"s":[0],"h":1},{"t":58,"s":[100],"h":1},{"t":59,"s":[100],"h":1},{"t":60,"s":[100],"h":1},{"t":61,"s":[100],"h":1},{"t":62,"s":[100],"h":1},{"t":63,"s":[100],"h":1},{"t":64,"s":[100],"h":1},{"t":65,"s":[100],"h":1},{"t":66,"s":[100],"h":1},{"t":67,"s":[100],"h":1},{"t":68,"s":[100],"h":1},{"t":69,"s":[100],"h":1},{"t":70,"s":[100],"h":1},{"t":71,"s":[100],"h":1},{"t":72,"s":[100],"h":1},{"t":73,"s":[100],"h":1},{"t":74,"s":[100],"h":1},{"t":75,"s":[100],"h":1},{"t":76,"s":[100],"h":1},{"t":77,"s":[100],"h":1},{"t":78,"s":[100],"h":1},{"t":79,"s":[100],"h":1},{"t":80,"s":[100],"h":1},{"t":81,"s":[100],"h":1},{"t":82,"s":[100],"h":1},{"t":83,"s":[100],"h":1},{"t":84,"s":[100],"h":1},{"t":85,"s":[100],"h":1},{"t":86,"s":[100],"h":1},{"t":87,"s":[100],"h":1},{"t":88,"s":[100],"h":1},{"t":89,"s":[100],"h":1},{"t":90,"s":[100],"h":1},{"t":91,"s":[100],"h":1},{"t":92,"s":[100],"h":1},{"t":93,"s":[100],"h":1},{"t":94,"s":[100],"h":1},{"t":95,"s":[100],"h":1},{"t":96,"s":[100],"h":1},{"t":97,"s":[0],"h":1},{"t":98,"s":[0],"h":1},{"t":99,"s":[0],"h":1},{"t":100,"s":[0],"h":1},{"t":101,"s":[0],"h":1},{"t":102,"s":[0],"h":1},{"t":103,"s":[0],"h":1},{"t":104,"s":[0],"h":1},{"t":105,"s":[0],"h":1},{"t":106,"s":[0],"h":1},{"t":107,"s":[0],"h":1},{"t":108,"s":[0],"h":1},{"t":109,"s":[0],"h":1},{"t":110,"s":[0],"h":1},{"t":111,"s":[0],"h":1},{"t":112,"s":[0],"h":1},{"t":113,"s":[0],"h":1},{"t":114,"s":[0],"h":1},{"t":115,"s":[0],"h":1},{"t":116,"s":[0],"h":1},{"t":117,"s":[0],"h":1},{"t":118,"s":[0],"h":1},{"t":119,"s":[0],"h":1}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":1,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.00213723752422,145.68435644436695],[175.2340243717899,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":2,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.01709790004887,145.68435709846247],[175.21906944484545,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.05770540750385,145.68436400280396],[175.1785224321597,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.1367831262165,145.68439896056552],[175.09975050807492,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":5,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.26715412914858,145.6845186598911],[174.97042363235647,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":6,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.46164040852113,145.68484083693517],[174.77873551150387,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100.73306087154158,145.68557791140813],[174.51367721260826,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":8,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.09422703281935,145.68707808794161],[174.16535254791262,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":9,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[101.55793479589938,145.68987390647064],[173.72532016609773,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.13695009169614,145.69473820474883],[173.18694334757186,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":11,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[102.84398541468727,145.70274741992375],[172.54572545701274,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":12,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[103.69166347102245,145.71535209549262],[171.79960753081258,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[104.69246323090135,145.73445436416338],[170.94920515022932,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":14,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[105.85864266890633,145.76249203264803],[169.99796493107306,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":15,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[107.20213139409789,145.8025286847221],[168.95222667312203,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":16,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[108.73438523824998,145.85834892439436],[167.82118509254207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[110.46619371861527,145.9345574790187],[166.61675437660267,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":18,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[112.40743016966178,146.03668034701371],[165.35334856518904,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":19,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[114.56673331575352,146.17126547853735],[164.04759992204188,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[116.9511082303112,146.34597959060318],[162.7180451066871,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":21,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[119.56543412751468,146.56969661159826],[161.3848145532102,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":22,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[122.41186643342385,146.85257189760665],[160.06936397733708,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[125.48912130867366,147.20609474441147],[158.79428893755144,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[128.79163252777767,147.6431098261423],[157.58326505555536,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":25,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[132.30857370939827,148.17779603433607],[156.46115967472625,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":26,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[136.02274375594828,148.82558880736312],[155.45436794990945,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":27,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[139.9093204855403,149.60302950658289],[154.5914412088589,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":28,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[143.93449736804646,150.52752284250107],[153.9041032849644,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[148.05403158786663,151.61698098210726],[153.42880012533328,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":30,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[152.21174892054898,152.88933106722158],[153.20901457569542,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[156.33807262878386,154.36186184207872],[153.2987300604207,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":32,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[160.34867008765178,156.0503854539823],[153.76769879656504,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":33,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[164.14334216845504,157.96819292461282],[154.70967411957213,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[167.60531609072166,160.12478711212304],[156.25574276290294,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[170.60114132569498,162.52438615639866],[158.5968892371398,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":36,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[172.98142804309384,165.16420448884634],[162.0242806171326,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":37,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[174.58270506836902,168.0325385968404],[167.0060458086607,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[175.2307042485341,171.10671188088455],[174.34610750400844,171.41663917972903],[100,28.583360820270954]],"c":true}],"h":1},{"t":39,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":40,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":41,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":42,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":43,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":44,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954],[100,28.583360820270954]],"c":true}],"h":1},{"t":49,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[35.842232959530165,171.41663917972903],[26.15046474746714,166.49908237253814]],"c":true}],"h":1},{"t":50,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.02171144300978,171.41663917972903],[30.035359501747664,161.95417685080264]],"c":true}],"h":1},{"t":51,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.330308413749286,171.41663917972903],[35.97759368381941,157.9009504134384]],"c":true}],"h":1},{"t":52,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.69211445112142,171.41663917972903],[43.524739673823944,154.41502126880033]],"c":true}],"h":1},{"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[46.54542176377046,171.41663917972903],[52.23930132816764,151.53412935182934]],"c":true}],"h":1},{"t":54,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[45.07094849513416,171.41663917972903],[61.71757321027329,149.26456770823847]],"c":true}],"h":1},{"t":55,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[42.28066065919667,171.41663917972903],[71.60176086831838,147.5878230578733]],"c":true}],"h":1},{"t":56,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[38.043131229331905,171.41663917972903],[81.58645024373618,146.4669291932127]],"c":true}],"h":1},{"t":57,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,28.583360820270954],[32.06728093014854,171.41663917972903],[91.42059682997896,145.85220900074034]],"c":true}],"h":1},{"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[24.76929575146589,171.10671188088457],[100.9061668000921,145.6862229301544],[100.9061668000921,145.6862229301544]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[25.417294931630963,168.03253859684042],[109.89444817484419,145.9078517026294],[109.89444817484419,145.9078517026294]],"c":true}],"h":1},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[27.01857195690613,165.16420448884637],[118.28089606093508,146.4555207003181],[118.28089606093508,146.4555207003181]],"c":true}],"h":1},{"t":61,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[29.398858674304975,162.5243861563987],[125.9992085169768,147.26962670455904],[125.9992085169768,147.26962670455904]],"c":true}],"h":1},{"t":62,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[32.394683909278314,160.12478711212307],[133.01516676125698,148.29425928080326],[133.01516676125698,148.29425928080326]],"c":true}],"h":1},{"t":63,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[35.856657831544936,157.96819292461282],[139.32062633606557,149.47832409788663],[139.32062633606557,149.47832409788663]],"c":true}],"h":1},{"t":64,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[39.65132991234821,156.0503854539823],[144.9279202573607,150.77617838683688],[144.9279202573607,150.77617838683688]],"c":true}],"h":1},{"t":65,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[43.661927371216095,154.36186184207872],[149.86483302362365,152.14788350454043],[149.86483302362365,152.14788350454043]],"c":true}],"h":1},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[47.78825107945101,152.88933106722158],[154.1702249825266,153.55916933063904],[154.1702249825266,153.55916933063904]],"c":true}],"h":1},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[51.94596841213336,151.61698098210726],[157.89032775993167,154.98119240867067],[157.89032775993167,154.98119240867067]],"c":true}],"h":1},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[56.06550263195349,150.52752284250107],[161.07569026230578,156.39015609296857],[161.07569026230578,156.39015609296857]],"c":true}],"h":1},{"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[60.09067951445965,149.6030295065829],[163.7787279457848,157.7668476672345],[163.7787279457848,157.7668476672345]],"c":true}],"h":1},{"t":70,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[63.97725624405165,148.82558880736312],[166.05181248302944,159.09613519733574],[166.05181248302944,159.09613519733574]],"c":true}],"h":1},{"t":71,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[67.69142629060164,148.1777960343361],[167.94583184319973,160.36645616899267],[167.94583184319973,160.36645616899267]],"c":true}],"h":1},{"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[71.20836747222228,147.6431098261423],[169.5091497314232,161.56932089599852],[169.5091497314232,161.56932089599852]],"c":true}],"h":1},{"t":73,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[74.5108786913263,147.20609474441147],[170.78689635032543,162.6988462564911],[170.78689635032543,162.6988462564911]],"c":true}],"h":1},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[77.58813356657613,146.85257189760665],[171.8205280053716,163.75132940997366],[171.8205280053716,163.75132940997366]],"c":true}],"h":1},{"t":75,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[80.43456587248531,146.56969661159826],[172.64760001130034,164.72486659520752],[172.64760001130034,164.72486659520752]],"c":true}],"h":1},{"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[83.04889176968875,146.34597959060318],[173.30170482167705,165.61901871326904],[173.30170482167705,165.61901871326904]],"c":true}],"h":1},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[85.43326668424646,146.17126547853735],[173.81253471130574,166.43452296328823],[173.81253471130574,166.43452296328823]],"c":true}],"h":1},{"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[87.59256983033822,146.03668034701371],[174.20603531105112,167.17304813479808],[174.20603531105112,167.17304813479808]],"c":true}],"h":1},{"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[89.53380628138467,145.9345574790187],[174.50462260185213,167.836990104014],[174.50462260185213,167.836990104014]],"c":true}],"h":1},{"t":80,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[91.26561476174996,145.85834892439436],[174.72744150919286,168.42930348868117],[174.72744150919286,168.42930348868117]],"c":true}],"h":1},{"t":81,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[92.79786860590211,145.8025286847221],[174.89064897156487,168.95336516806938],[174.89064897156487,168.95336516806938]],"c":true}],"h":1},{"t":82,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[94.14135733109363,145.76249203264803],[175.00770831106672,169.4128653744158],[175.00770831106672,169.4128653744158]],"c":true}],"h":1},{"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[95.30753676909862,145.73445436416338],[175.08968496955248,169.81172223294573],[175.08968496955248,169.81172223294573]],"c":true}],"h":1},{"t":84,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[96.30833652897755,145.71535209549262],[175.14553626674518,170.1540159102598],[175.14553626674518,170.1540159102598]],"c":true}],"h":1},{"t":85,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.1560145853127,145.70274741992375],[175.18238987263604,170.44393888058158],[175.18238987263604,170.44393888058158]],"c":true}],"h":1},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[97.8630499083038,145.69473820474883],[175.20580725120632,170.6857592030873],[175.20580725120632,170.6857592030873]],"c":true}],"h":1},{"t":87,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.44206520410059,145.68987390647064],[175.2200295079158,170.88379409754347],[175.2200295079158,170.88379409754347]],"c":true}],"h":1},{"t":88,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[98.9057729671806,145.68707808794161],[175.22820393443305,171.04239149313332],[175.22820393443305,171.04239149313332]],"c":true}],"h":1},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.26693912845845,145.68557791140813],[175.23259015718264,171.16591759537795],[175.23259015718264,171.16591759537795]],"c":true}],"h":1},{"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.53835959147887,145.68484083693517],[175.2347452187699,171.25874886104174],[175.2347452187699,171.25874886104174]],"c":true}],"h":1},{"t":91,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.7328458708514,145.6845186598911],[175.23568720142907,171.32526708618758],[175.23568720142907,171.32526708618758]],"c":true}],"h":1},{"t":92,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.86321687378347,145.68439896056552],[175.2360371788439,171.36985659529591],[175.2360371788439,171.36985659529591]],"c":true}],"h":1},{"t":93,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.94229459249615,145.68436400280396],[175.23613938850093,171.3969027679839],[175.23613938850093,171.3969027679839]],"c":true}],"h":1},{"t":94,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.98290209995109,145.6843570984625],[175.2361595754449,171.41079135350373],[175.2361595754449,171.41079135350373]],"c":true}],"h":1},{"t":95,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99786276247576,145.68435644436695],[175.23616148789233,171.41590820144467],[175.23616148789233,171.41590820144467]],"c":true}],"h":1},{"t":96,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[99.99999999999999,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":97,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":98,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":99,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":100,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":101,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":102,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":103,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":104,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":105,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":106,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":107,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":108,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":109,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":110,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":111,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":112,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":113,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":114,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":115,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":116,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":117,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":118,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1},{"t":119,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[100,28.583360820270954],[100,145.68435643398448],[175.23616151824865,171.41663917972903],[175.23616151824865,171.41663917972903]],"c":true}],"h":1}]}},{"ty":"fl","c":{"a":0,"k":[0.5294117647058824,0.3568627450980392,0.0784313725490196,1]},"o":{"a":0,"k":100},"r":1,"bm":0},{"ty":"st","c":{"a":0,"k":[0.9607843137254902,0.6470588235294118,0.1411764705882353,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2.9166666666666665},"lc":2,"lj":2,"ml":4,"bm":0},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"face"}],"ip":0,"op":120,"st":0,"bm":0}]} \ No newline at end of file diff --git a/loader icon/pyramid-loader.svg b/loader icon/pyramid-loader.svg new file mode 100644 index 0000000..6246393 --- /dev/null +++ b/loader icon/pyramid-loader.svg @@ -0,0 +1,53 @@ + + + + + \ No newline at end of file diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 4445597..03d6ff3 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,21 +6,47 @@ import FlutterMacOS import Foundation import connectivity_plus +import desktop_drop import device_info_plus -import dynamic_color +import file_picker +import firebase_core +import firebase_messaging +import flutter_local_notifications import flutter_secure_storage_darwin import flutter_webrtc import livekit_client +import local_notifier +import media_kit_libs_macos_video +import media_kit_video +import package_info_plus +import pdfx import shared_preferences_foundation +import sqflite_darwin import sqlcipher_flutter_libs +import url_launcher_macos +import volume_controller +import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) - DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin")) + LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) + MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) + MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PdfxPlugin.register(with: registry.registrar(forPlugin: "PdfxPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index a2ec33f..96d3fee 100644 --- a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" + "info": { + "version": 1, + "author": "xcode" }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 82b6f9d..ad5d766 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 13b35eb..cb66267 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 0a3f5fa..531dede 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index bdb5722..b363548 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index f083318..2f25f49 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 326c0e7..bb83aec 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 2f1632c..f5dcc16 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/mockup/App.jsx b/mockup/App.jsx new file mode 100644 index 0000000..9731dc1 --- /dev/null +++ b/mockup/App.jsx @@ -0,0 +1,478 @@ +/* Pyramid · main app */ + +const ACCENT_PRESETS = [ + { name: 'amber', h: 42, s: 95, l: 58 }, + { name: 'coral', h: 12, s: 85, l: 62 }, + { name: 'mint', h: 160, s: 65, l: 50 }, + { name: 'violet', h: 268, s: 78, l: 66 }, + { name: 'cobalt', h: 220, s: 85, l: 62 }, + { name: 'lime', h: 80, s: 70, l: 55 }, +]; + +const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ + "accentIdx": 0, + "theme": "dark", + "density": 1, + "motion": 1, + "radius": 12, + "view": "chat" +}/*EDITMODE-END*/; + +const loadTweak = (k, d) => { + try { const v = localStorage.getItem('pyr_' + k); return v === null ? d : JSON.parse(v); } catch { return d; } +}; +const saveTweak = (k, v) => { try { localStorage.setItem('pyr_' + k, JSON.stringify(v)); } catch {} }; + +const DEFAULT_CALL_PARTICIPANTS = [ + { name: 'juno', avatar: 'J', color: '#06b6d4', speaking: true, muted: false, cam: false }, + { name: 'sachi', avatar: 'S', color: '#a855f7', speaking: false, muted: false, cam: false }, + { name: 'mira (you)', avatar: 'M', color: '#ff6b9d', speaking: false, muted: false, cam: false, self: true }, + { name: 'ren', avatar: 'R', color: '#10b981', speaking: false, muted: true, cam: false }, +]; + +const App = () => { + const [theme, setTheme] = React.useState(() => loadTweak('theme', TWEAK_DEFAULTS.theme)); + const [accentIdx, setAccentIdx] = React.useState(() => loadTweak('accentIdx', TWEAK_DEFAULTS.accentIdx)); + const [density, setDensity] = React.useState(() => loadTweak('density', TWEAK_DEFAULTS.density)); + const [motion, setMotion] = React.useState(() => loadTweak('motion', TWEAK_DEFAULTS.motion)); + const [radius, setRadius] = React.useState(() => loadTweak('radius', TWEAK_DEFAULTS.radius)); + + const [loggedIn, setLoggedIn] = React.useState(() => loadTweak('loggedIn', true)); + const [view, setView] = React.useState(() => { + // never land on voice without an active call + const v = loadTweak('view', 'chat'); + return v === 'voice' ? 'chat' : v; + }); + const [activeSpace, setActiveSpace] = React.useState(() => loadTweak('space', 'atelier')); + const [activeRoom, setActiveRoom] = React.useState(() => loadTweak('room', 'general')); + const [railCollapsed, setRailCollapsed] = React.useState(false); + const [membersOpen, setMembersOpen] = React.useState(true); + const [tweaksMode, setTweaksMode] = React.useState(false); + + const [showSettings, setShowSettings] = React.useState(false); + const [showSearch, setShowSearch] = React.useState(false); + const [profilePopover, setProfilePopover] = React.useState(null); + const [emojiPickerAt, setEmojiPickerAt] = React.useState(null); + const [messageActions, setMessageActions] = React.useState(null); + const [toast, setToast] = React.useState(null); + + const [messagesState, setMessagesState] = React.useState(() => window.PYRAMID_DATA.messagesByRoom); + + /* call state — persists across navigation */ + const [call, setCall] = React.useState(null); + const [callReturnRoom, setCallReturnRoom] = React.useState(null); + + React.useEffect(() => { saveTweak('theme', theme); }, [theme]); + React.useEffect(() => { saveTweak('accentIdx', accentIdx); }, [accentIdx]); + React.useEffect(() => { saveTweak('density', density); }, [density]); + React.useEffect(() => { saveTweak('motion', motion); }, [motion]); + React.useEffect(() => { saveTweak('radius', radius); }, [radius]); + React.useEffect(() => { saveTweak('view', view); }, [view]); + React.useEffect(() => { saveTweak('space', activeSpace); }, [activeSpace]); + React.useEffect(() => { saveTweak('room', activeRoom); }, [activeRoom]); + React.useEffect(() => { saveTweak('loggedIn', loggedIn); }, [loggedIn]); + + React.useEffect(() => { + const a = ACCENT_PRESETS[accentIdx]; + const r = document.documentElement; + r.setAttribute('data-theme', theme); + r.style.setProperty('--accent-h', a.h); + r.style.setProperty('--accent-s', a.s + '%'); + r.style.setProperty('--accent-l', a.l + '%'); + r.style.setProperty('--density', density); + r.style.setProperty('--motion', motion); + r.style.setProperty('--r-base', radius + 'px'); + }, [theme, accentIdx, density, motion, radius]); + + React.useEffect(() => { + const handler = (e) => { + if (e.data?.type === '__activate_edit_mode') setTweaksMode(true); + if (e.data?.type === '__deactivate_edit_mode') setTweaksMode(false); + }; + window.addEventListener('message', handler); + window.parent?.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', handler); + }, []); + + const pushEdits = (edits) => { + try { window.parent?.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); } catch {} + }; + + React.useEffect(() => { + const h = (e) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setShowSearch(true); } + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') { e.preventDefault(); setRailCollapsed(v => !v); } + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'u') { e.preventDefault(); setMembersOpen(v => !v); } + if (e.key === 'Escape') { + setShowSearch(false); setShowSettings(false); + setProfilePopover(null); setEmojiPickerAt(null); setMessageActions(null); + } + }; + window.addEventListener('keydown', h); + return () => window.removeEventListener('keydown', h); + }, []); + + const showToast = (msg, icon = 'check') => { + setToast({ msg, icon }); + setTimeout(() => setToast(null), 2400); + }; + + const handleSelectSpace = (spaceId) => { + setActiveSpace(spaceId); + setView('chat'); + const data = window.PYRAMID_DATA.rooms[spaceId]; + if (data) { + const firstActive = data.sections.flatMap(s => s.rooms).find(r => r.active) || data.sections[0].rooms[0]; + setActiveRoom(firstActive.id); + } + }; + + const handleSelectRoom = (roomId) => { + setActiveRoom(roomId); + const data = window.PYRAMID_DATA.rooms[activeSpace]; + const roomDef = data?.sections.flatMap(s => s.rooms).find(r => r.id === roomId); + if (roomDef?.type === 'voice') { + // joining a voice room starts a call for that room + startCall({ roomName: roomDef.name, kind: 'voice', room: roomDef }); + setView('voice'); + } else { + setView('chat'); + } + }; + + const startCall = ({ roomName, kind, room }) => { + setCall({ + active: true, + roomName, + kind, + participants: DEFAULT_CALL_PARTICIPANTS, + mic: true, cam: kind === 'video', deaf: false, + streaming: false, streamCollapsed: false, + streamer: 'mira (you)', + roomId: room?.id || null, + }); + setCallReturnRoom({ space: activeSpace, room: activeRoom }); + }; + + /* called from DM header */ + React.useEffect(() => { + window.__startDMCall = (roomDef, kind) => { + startCall({ roomName: roomDef?.name || 'DM', kind, room: roomDef }); + setView('voice'); + }; + return () => { window.__startDMCall = null; }; + }, [activeSpace, activeRoom]); + + const handleLeaveCall = () => { + setCall(null); + if (view === 'voice') { + if (callReturnRoom) { + setActiveSpace(callReturnRoom.space); + setActiveRoom(callReturnRoom.room); + } + setView('chat'); + } + setCallReturnRoom(null); + showToast('left the call', 'phoneOff'); + }; + + const updateCall = (patch) => setCall(c => c ? { ...c, ...patch } : c); + + const handleJumpToCall = () => { + setView('voice'); + }; + + const handleSend = (text) => { + const newMsg = { + id: 'm' + Date.now(), + author: 'mira', + avatar: 'M', + color: '#ff6b9d', + time: new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), + text, + }; + setMessagesState(prev => ({ + ...prev, + [activeRoom]: [...(prev[activeRoom] || []), newMsg], + })); + }; + + const handleReact = (msgId, emoji) => { + setMessagesState(prev => { + const list = [...(prev[activeRoom] || [])]; + const idx = list.findIndex(m => m.id === msgId); + if (idx < 0) return prev; + const msg = { ...list[idx] }; + const reactions = [...(msg.reactions || [])]; + const existing = reactions.findIndex(r => r.emoji === emoji); + if (existing >= 0) { + const r = { ...reactions[existing] }; + if (r.mine) { r.mine = false; r.count--; } else { r.mine = true; r.count++; } + if (r.count <= 0) reactions.splice(existing, 1); + else reactions[existing] = r; + } else { + reactions.push({ emoji, count: 1, mine: true }); + } + msg.reactions = reactions; + list[idx] = msg; + return { ...prev, [activeRoom]: list }; + }); + }; + + const handleOpenProfile = (name, e) => { + const rect = e?.currentTarget?.getBoundingClientRect(); + const profiles = { + juno: { name: 'juno', handle: '@juno:pyramid.chat', avatar: 'J', color: '#06b6d4', status: 'online', about: 'risograph, zines, tea. teaches at the atelier on tuesdays.', roles: [{ name: 'founder', color: '#f59e0b' }, { name: 'print-nerd', color: '#06b6d4' }] }, + sachi: { name: 'sachi', handle: '@sachi:pyramid.chat', avatar: 'S', color: '#a855f7', status: 'online', about: 'illustration, collage, usually reading', roles: [{ name: 'mod', color: '#a855f7' }] }, + ren: { name: 'ren', handle: '@ren:pyramid.chat', avatar: 'R', color: '#10b981', status: 'busy', about: 'graphic design studio of one', roles: [] }, + kai: { name: 'kai', handle: '@kai:pyramid.chat', avatar: 'K', color: '#f43f5e', status: 'away', about: '3d + motion. fancy renders upon request.', roles: [] }, + ezra: { name: 'ezra', handle: '@ezra:pyramid.chat', avatar: 'E', color: '#3b82f6', status: 'online', about: 'keeps the lights on. dev-lab lurker.', roles: [{ name: 'dev', color: '#3b82f6' }] }, + mira: { name: 'mira', handle: '@mira:pyramid.chat', avatar: 'M', color: '#ff6b9d', status: 'online', about: 'painter · printmaker · pastry enthusiast', roles: [{ name: 'founder', color: '#f59e0b' }] }, + }; + setProfilePopover({ anchor: rect, profile: profiles[name] || profiles.juno }); + }; + + const handleOpenEmoji = (target, e) => { + const rect = e?.currentTarget?.getBoundingClientRect(); + setEmojiPickerAt({ target, rect }); + }; + + const handlePickEmoji = (emoji) => { + if (emojiPickerAt?.target === 'composer') { + showToast(`${emoji} added`, 'check'); + } else if (emojiPickerAt?.target) { + handleReact(emojiPickerAt.target, emoji); + } + }; + + const handleOpenActions = (msgId, e) => { + const rect = e?.currentTarget?.getBoundingClientRect(); + setMessageActions({ msgId, rect }); + }; + + const handleMsgAction = (action) => { + const labels = { + reply: 'replying…', thread: 'thread opened', edit: 'editing message', + pin: 'pinned to room', bookmark: 'saved for later', + copy: 'copied', link: 'message link copied', delete: 'message deleted', + }; + showToast(labels[action] || action); + }; + + if (!loggedIn) { + return setLoggedIn(true)} />; + } + + const spaces = window.PYRAMID_DATA.spaces; + const roomsData = window.PYRAMID_DATA.rooms[activeSpace] || window.PYRAMID_DATA.rooms.atelier; + const messages = messagesState[activeRoom] || []; + const members = window.PYRAMID_DATA.membersByRoom[activeRoom] || window.PYRAMID_DATA.membersByRoom.general; + + const showMembers = membersOpen && view !== 'voice'; + + const appClasses = [ + 'app', + railCollapsed && 'rail-collapsed', + showMembers && 'right-open', + ].filter(Boolean).join(' '); + + return ( + <> +
+ + handleOpenProfile('mira', e)} + onOpenSettings={() => setShowSettings(true)} + call={call} + onLeaveCall={handleLeaveCall} + onToggleStreamCollapse={() => call && updateCall({ streamCollapsed: !call.streamCollapsed })} + onToggleMic={() => call && updateCall({ mic: !call.mic })} + onToggleDeaf={() => call && updateCall({ deaf: !call.deaf })} + onToggleScreen={() => call && updateCall({ streaming: !call.streaming, streamCollapsed: false })} + onToggleCam={() => call && updateCall({ cam: !call.cam })} + onJumpToCall={handleJumpToCall} + /> + {view === 'voice' ? ( + setRailCollapsed(v => !v)} + onOpenProfile={handleOpenProfile} + onToggleMic={() => call && updateCall({ mic: !call.mic })} + onToggleDeaf={() => call && updateCall({ deaf: !call.deaf })} + onToggleCam={() => call && updateCall({ cam: !call.cam })} + onToggleScreen={() => call && updateCall({ streaming: !call.streaming, streamCollapsed: false })} + /> + ) : ( + setMembersOpen(v => !v)} + membersOpen={membersOpen} + onOpenSearch={() => setShowSearch(true)} + onToggleRail={() => setRailCollapsed(v => !v)} + railCollapsed={railCollapsed} + onSend={handleSend} + onReact={handleReact} + /> + )} + {showMembers && } +
+ + { + if (v === 'voice') { + if (!call) startCall({ roomName: 'The Lounge', kind: 'voice', room: { id: 'lounge', name: 'The Lounge' } }); + setView('voice'); + } + else if (v === 'dm') { setActiveSpace('friends'); setActiveRoom('dm-juno'); setView('chat'); } + else if (v === 'chat') { setActiveSpace('atelier'); setActiveRoom('general'); setView('chat'); } + }} + onLogin={() => setLoggedIn(false)} + onSearch={() => setShowSearch(true)} + onSettings={() => setShowSettings(true)} + onProfile={(e) => handleOpenProfile('juno', e)} + onEmoji={(e) => handleOpenEmoji('composer', e)} + onTheme={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} + theme={theme} + /> + + {showSettings && ( + setShowSettings(false)} + theme={theme} + onTheme={setTheme} + accentPresets={ACCENT_PRESETS} + accent={ACCENT_PRESETS[accentIdx].h} + onAccent={(p) => setAccentIdx(ACCENT_PRESETS.findIndex(a => a.name === p.name))} + density={density} + onDensity={setDensity} + motion={motion} + onMotion={setMotion} + radius={radius} + onRadius={setRadius} + /> + )} + {showSearch && setShowSearch(false)} onJump={() => setShowSearch(false)} />} + {profilePopover && ( + setProfilePopover(null)} + /> + )} + {emojiPickerAt && ( + setEmojiPickerAt(null)} + /> + )} + {messageActions && ( + setMessageActions(null)} + /> + )} + {toast && ( +
+ + {toast.msg} +
+ )} + + {tweaksMode && ( + { setTheme(v); pushEdits({ theme: v }); }} + accentIdx={accentIdx} setAccentIdx={(v) => { setAccentIdx(v); pushEdits({ accentIdx: v }); }} + density={density} setDensity={(v) => { setDensity(v); pushEdits({ density: v }); }} + motion={motion} setMotion={(v) => { setMotion(v); pushEdits({ motion: v }); }} + radius={radius} setRadius={(v) => { setRadius(v); pushEdits({ radius: v }); }} + /> + )} + + ); +}; + +const DemoSwitcher = ({ view, setView, onLogin, onSearch, onSettings, onProfile, onEmoji, onTheme, theme }) => { + return ( +
+ + + +
+ + + + + +
+ +
+ ); +}; + +const TweaksPanel = ({ theme, setTheme, accentIdx, setAccentIdx, density, setDensity, motion, setMotion, radius, setRadius }) => ( +
+
+
Tweaks
+ pyramid.sys +
+
+
+
Theme{theme}
+
+ + +
+
+
+
Accent{ACCENT_PRESETS[accentIdx].name}
+
+ {ACCENT_PRESETS.map((p, i) => ( +
setAccentIdx(i)} title={p.name} /> + ))} +
+
+
+
Density{density === 0.85 ? 'compact' : density === 1.2 ? 'cozy' : 'default'}
+
+ + + +
+
+
+
Corner radius{radius}px
+ setRadius(+e.target.value)} /> +
+
+
Motion{Math.round(motion*100)}%
+ setMotion(+e.target.value/100)} /> +
+
+
+); + +ReactDOM.createRoot(document.getElementById('root')).render(); diff --git a/mockup/Chat.jsx b/mockup/Chat.jsx new file mode 100644 index 0000000..9f18df9 --- /dev/null +++ b/mockup/Chat.jsx @@ -0,0 +1,273 @@ +/* Chat view — messages, composer, hover actions */ + +const renderText = (text, onMention) => { + if (!text) return null; + const parts = []; + const regex = /(@\w+|#[\w-]+|https?:\/\/\S+|`[^`]+`)/g; + let last = 0; + text.replace(regex, (m, _, idx) => { + if (idx > last) parts.push(text.slice(last, idx)); + if (m.startsWith('@')) parts.push( onMention?.(m)}>{m}); + else if (m.startsWith('#')) parts.push(
{m}); + else if (m.startsWith('http')) parts.push({m}); + else if (m.startsWith('`')) parts.push({m.slice(1, -1)}); + last = idx + m.length; + return m; + }); + if (last < text.length) parts.push(text.slice(last)); + return parts; +}; + +const Message = ({ msg, onOpenProfile, onReact, onOpenActions, onOpenEmoji }) => { + const hasAvatar = !msg.continuation; + return ( +
+
+ {hasAvatar ? ( +
onOpenProfile?.(msg.author, e)}> + {msg.avatar} +
+ ) : ( +
{msg.time}
+ )} +
+
+ {hasAvatar && ( +
+ onOpenProfile?.(msg.author, e)}>{msg.author} + {msg.time} +
+ )} + {msg.text &&
{renderText(msg.text)}
} + {msg.image && ( +
+ {msg.image} +
+ )} + {msg.reactions && msg.reactions.length > 0 && ( +
+ {msg.reactions.map((r, i) => ( + + ))} + +
+ )} +
+
+ + + + +
+
+ ); +}; + +const ChatHeader = ({ room, roomsData, onToggleMembers, membersOpen, onOpenSearch, onToggleRail, railCollapsed }) => { + const roomDef = findRoom(roomsData, room); + const isDM = roomDef?.type === 'dm'; + const title = roomDef?.name || room; + const topic = isDM + ? `direct message with ${roomDef.name}` + : 'hi-fi corner for paintings, zines, riso, and whatever else'; + + return ( +
+ +
+ {isDM ? ( +
+ {roomDef.avatar} + +
+ ) : ( + {roomIcon(roomDef?.type || 'text')} + )} + {title} +
+ {!isDM &&
{topic}
} + {isDM &&
} +
+ {isDM && ( + <> + + +
+ + )} + + + + +
+
+ ); +}; + +const findRoom = (roomsData, id) => { + if (!roomsData) return null; + for (const section of roomsData.sections) { + const r = section.rooms.find(r => r.id === id); + if (r) return r; + } + return null; +}; + +const Composer = ({ room, onSend, onOpenEmoji }) => { + const [text, setText] = React.useState(''); + const ref = React.useRef(null); + + const submit = () => { + if (text.trim()) { + onSend(text); + setText(''); + } + }; + + const handleKey = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }; + + const roomName = room?.name || room; + const placeholder = typeof roomName === 'string' && roomName.startsWith('dm-') + ? `Message…` + : `Message #${typeof roomName === 'string' ? roomName.replace('dm-', '') : 'room'}`; + + return ( +
+
+
+ +