25ed765a03
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
208 lines
8.9 KiB
Dart
208 lines
8.9 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/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;
|
|
});
|