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

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

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