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/features/rooms/rooms_provider.dart'; // ─── Theme state ────────────────────────────────────────────────────────────── final themeModeProvider = StateProvider((ref) => true); // true = dark final accentProvider = StateProvider((ref) => 0); // index into accent presets const accentPresets = [ (name: 'amber', color: PyramidColors.accentAmber), (name: 'coral', color: PyramidColors.accentCoral), (name: 'mint', color: PyramidColors.accentMint), (name: 'violet', color: PyramidColors.accentViolet), (name: 'cobalt', color: PyramidColors.accentCobalt), (name: 'lime', color: PyramidColors.accentLime), ]; final radiusProvider = StateProvider((ref) => 12); final densityProvider = StateProvider((ref) => 1); final motionProvider = StateProvider((ref) => 1); // ─── Shell layout state ─────────────────────────────────────────────────────── final railCollapsedProvider = StateProvider((ref) => false); // True while the app window is the foreground / focused window. // Set to false when the lifecycle transitions to inactive/paused/detached. final windowFocusedProvider = StateProvider((ref) => true); final membersPanelOpenProvider = StateProvider((ref) => false); // Set to an eventId to make the open chat scroll to + briefly highlight that // message (e.g. when tapping a search result). ChatView consumes and clears it. final pendingJumpEventProvider = StateProvider((ref) => null); // Currently selected room ID (null = no room selected) final activeRoomIdProvider = StateProvider((ref) => null); // ─── Share-Target ("Teilen nach Pyramid", Android) ─────────────────────────── // Von MainActivity.kt via MethodChannel geliefert; AppShell zeigt dann den // Raum-Picker. paths = bereits in den App-Cache kopierte Dateien. class PendingShare { final String? text; final List paths; const PendingShare({this.text, this.paths = const []}); bool get isEmpty => (text == null || text!.isEmpty) && paths.isEmpty; } final pendingShareProvider = StateProvider((ref) => null); // Space filter: null = all rooms, otherwise space ID final activeSpaceIdProvider = StateProvider((ref) => 'dms'); // ─── Modal state ───────────────────────────────────────────────────────────── enum ModalKind { none, settings, search } final activeModalProvider = StateProvider((ref) => ModalKind.none); // ─── Call state ─────────────────────────────────────────────────────────────── // Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen: // `callSignalingProvider` (features/call_signaling/call_signaling_service.dart) // und `voiceChannelProvider` (features/voice_channel/voice_channel_service.dart). // ─── View mode (chat / voice) ───────────────────────────────────────────────── enum ViewMode { chat, voice } final viewModeProvider = StateProvider((ref) => ViewMode.chat); // ─── Spaces from Matrix ─────────────────────────────────────────────────────── final spacesProvider = StreamProvider>((ref) async* { final client = await ref.watch(matrixClientProvider.future); yield _getSpaces(client); await for (final _ in client.onSync.stream) { yield _getSpaces(client); } }); List _getSpaces(Client client) { return client.rooms.where((r) => r.isSpace).toList(); } // ─── Toast notifications ───────────────────────────────────────────────────── class ToastMessage { final String text; final String icon; ToastMessage(this.text, this.icon); } final toastProvider = StateProvider((ref) => null); // Remembers the last active room per space section (key = spaceId or null) final lastRoomPerSpaceProvider = StateProvider>((ref) => {}); // Space IDs the user just accepted an invite for. The server confirms the join // immediately, but Continuwuity's incremental /sync sometimes does not flip the // local membership from invite → join. We treat these as joined optimistically // so the UI switches to the channel list (channels load via the space hierarchy). // Persisted so the user does not have to re-accept on every app start. final forceJoinedSpacesProvider = StateNotifierProvider>( (ref) => StringSetPref('force_joined_spaces')); // ─── Voice channel state ────────────────────────────────────────────────────── // ID of the Matrix room the user is currently in as a voice channel (null = none) final activeVoiceRoomIdProvider = StateProvider((ref) => null); const kVoicePresenceType = 'io.pyramid.voice.presence'; // Local override cache: spaceId → bannerMxcUri (set immediately on upload, // before the server sync delivers the state event back) final spaceBannerOverrideProvider = StateProvider>((ref) => {}); // ─── Server-level banner ────────────────────────────────────────────────────── // Persisted to SharedPreferences keyed by homeserver URL. // Key in store: homeserverUrl → mxcUri class ServerBannerNotifier extends AsyncNotifier> { @override Future> build() => loadAllServerBanners(); Future set(String homeserverUrl, String? mxcUri) async { final current = Map.from(state.valueOrNull ?? {}); if (mxcUri == null || mxcUri.isEmpty) { current.remove(homeserverUrl); } else { current[homeserverUrl] = mxcUri; } await saveServerBanner(homeserverUrl, mxcUri); state = AsyncData(current); } } final serverBannerProvider = AsyncNotifierProvider>( ServerBannerNotifier.new, ); // Active voice participants for a room, read from Matrix state events final voiceParticipantsProvider = Provider.family>, String>((ref, roomId) { ref.watch(roomListProvider); // Rebuild on every sync so presence updates show final client = ref.watch(matrixClientProvider).valueOrNull; if (client == null) return []; final room = client.getRoomById(roomId); if (room == null) return []; final states = room.states[kVoicePresenceType] ?? {}; final now = DateTime.now().millisecondsSinceEpoch; final result = >[]; for (final entry in states.entries) { final content = entry.value.content; if (content['active'] != true) continue; final joinedAt = content['joined_at'] as int? ?? 0; if (now - joinedAt > 10 * 60 * 1000) continue; result.add({ 'userId': entry.key, 'displayName': content['display_name'] as String? ?? entry.key, 'avatarUrl': content['avatar_url'] as String?, }); } // Optimistically insert the local user immediately (state event may not // have synced back from the server yet after joining) final activeVoiceRoomId = ref.watch(activeVoiceRoomIdProvider); if (activeVoiceRoomId == roomId && client.userID != null) { final userId = client.userID!; if (!result.any((p) => p['userId'] == userId)) { String displayName = userId.split(':').first.replaceAll('@', ''); String? avatarUrl; for (final r in client.rooms) { try { final m = r.getParticipants().firstWhere((u) => u.id == userId); if (m.displayName != null) { displayName = m.displayName!; avatarUrl = m.avatarUrl?.toString(); break; } } catch (_) {} } result.insert(0, { 'userId': userId, 'displayName': displayName, 'avatarUrl': avatarUrl, }); } } return result; });