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:
+94
-16
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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']),
|
||||
];
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)}'),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'))),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user