Files
pyramid/lib/core/app_state.dart
Bernd Steckmeister 41bcaf2a88 chore: toten incomingCallProvider aus app_state.dart entfernt (seit M2-Call-Pilot unreferenziert)
Repo-weit verifiziert: einzige Fundstelle war die Definition selbst -
der Pilot hat die Call-Provider ersatzlos in die Module verlegt
(callSignalingProvider/voiceChannelProvider), dieser StateProvider
blieb ungenutzt zurueck. analyze sauber, 29 Tests gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:48:16 +02:00

199 lines
8.8 KiB
Dart

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<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 ───────────────────────────────────────────────────────────────
// 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<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;
});