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:
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user