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
367 lines
10 KiB
Markdown
367 lines
10 KiB
Markdown
# 12 — Schnellreferenz: Typen, Konstanten & Patterns
|
|
|
|
---
|
|
|
|
## Wichtige Enum-Werte
|
|
|
|
```dart
|
|
// Membership
|
|
Membership.join // Mitglied
|
|
Membership.invite // Eingeladen
|
|
Membership.leave // Verlassen
|
|
Membership.ban // Gesperrt
|
|
Membership.knock // Klopf-Anfrage
|
|
|
|
// JoinRules
|
|
JoinRules.public // Offen
|
|
JoinRules.invite // Nur per Einladung
|
|
JoinRules.knock // Beitrittsanfragen erlaubt
|
|
JoinRules.restricted // Nur Mitglieder bestimmter Spaces/Räume
|
|
JoinRules.knockRestricted
|
|
|
|
// HistoryVisibility
|
|
HistoryVisibility.worldReadable // Ohne Login lesbar
|
|
HistoryVisibility.shared // Alle Mitglieder sehen History
|
|
HistoryVisibility.invited // Ab Einladungszeitpunkt
|
|
HistoryVisibility.joined // Ab Beitrittszeitpunkt
|
|
|
|
// GuestAccess
|
|
GuestAccess.canJoin // Gäste erlaubt
|
|
GuestAccess.forbidden // Keine Gäste
|
|
|
|
// Visibility
|
|
Visibility.public // Im Verzeichnis
|
|
Visibility.private // Nicht im Verzeichnis
|
|
|
|
// CreateRoomPreset
|
|
CreateRoomPreset.privateChat // invite + joined History
|
|
CreateRoomPreset.publicChat // public + shared History
|
|
CreateRoomPreset.trustedPrivateChat // wie private + alle Mitglieder sind Admins
|
|
|
|
// EventStatus
|
|
EventStatus.sending // Wird übertragen
|
|
EventStatus.sent // Server-ACK
|
|
EventStatus.synced // Im Sync empfangen
|
|
EventStatus.error // Fehlgeschlagen
|
|
EventStatus.removed // Gelöscht
|
|
|
|
// PresenceType
|
|
PresenceType.online
|
|
PresenceType.unavailable
|
|
PresenceType.offline
|
|
|
|
// ThumbnailMethod
|
|
ThumbnailMethod.crop // Ausschneiden auf exakte Größe
|
|
ThumbnailMethod.scale // Skalieren ohne Abschneiden
|
|
|
|
// LoginType
|
|
LoginType.mLoginPassword
|
|
LoginType.mLoginToken
|
|
LoginType.mLoginSso
|
|
|
|
// PushRuleKind
|
|
PushRuleKind.override
|
|
PushRuleKind.content
|
|
PushRuleKind.room
|
|
PushRuleKind.sender
|
|
PushRuleKind.underride
|
|
```
|
|
|
|
---
|
|
|
|
## EventTypes Konstanten
|
|
|
|
```dart
|
|
EventTypes.Message // m.room.message
|
|
EventTypes.Sticker // m.sticker
|
|
EventTypes.Reaction // m.reaction
|
|
EventTypes.RoomRedaction // m.room.redaction
|
|
EventTypes.Encrypted // m.room.encrypted
|
|
EventTypes.RoomCreate // m.room.create
|
|
EventTypes.RoomName // m.room.name
|
|
EventTypes.RoomTopic // m.room.topic
|
|
EventTypes.RoomAvatar // m.room.avatar
|
|
EventTypes.RoomMember // m.room.member
|
|
EventTypes.RoomPowerLevels // m.room.power_levels
|
|
EventTypes.RoomCanonicalAlias // m.room.canonical_alias
|
|
EventTypes.RoomJoinRules // m.room.join_rules
|
|
EventTypes.RoomGuestAccess // m.room.guest_access
|
|
EventTypes.HistoryVisibility // m.room.history_visibility
|
|
EventTypes.RoomPinnedEvents // m.room.pinned_events
|
|
EventTypes.RoomTombstone // m.room.tombstone
|
|
EventTypes.Encryption // m.room.encryption
|
|
EventTypes.SpaceChild // m.space.child
|
|
EventTypes.SpaceParent // m.space.parent
|
|
EventTypes.CallInvite // m.call.invite
|
|
EventTypes.CallAnswer // m.call.answer
|
|
EventTypes.CallHangup // m.call.hangup
|
|
EventTypes.CallCandidates // m.call.candidates
|
|
EventTypes.CallReject // m.call.reject
|
|
EventTypes.CallSelectAnswer // m.call.select_answer
|
|
EventTypes.CallNegotiate // m.call.negotiate
|
|
```
|
|
|
|
---
|
|
|
|
## Häufige Content-Zugriffe
|
|
|
|
```dart
|
|
// Sicher auf Map-Inhalte zugreifen (Matrix SDK Extension)
|
|
final text = event.content.tryGet<String>('body');
|
|
final map = event.content.tryGetMap<String, dynamic>('info');
|
|
final list = event.content.tryGetList<String>('aliases');
|
|
|
|
// Event-Inhalt strukturiert
|
|
final msgtype = event.content['msgtype'] as String?;
|
|
final body = event.content['body'] as String?;
|
|
final formatted = event.content['formatted_body'] as String?;
|
|
final format = event.content['format'] as String?; // 'org.matrix.custom.html'
|
|
|
|
// Relation
|
|
final relatesTo = event.content
|
|
.tryGetMap<String, dynamic>('m.relates_to');
|
|
final relType = relatesTo?.tryGet<String>('rel_type');
|
|
final eventId = relatesTo?.tryGet<String>('event_id');
|
|
|
|
// Verschlüsselte Medien
|
|
final file = event.content.tryGetMap<String, dynamic>('file');
|
|
final url = file?.tryGet<String>('url'); // mxc://
|
|
final key = file?.tryGetMap<String, dynamic>('key');
|
|
|
|
// Mentions (MSC3952)
|
|
final mentions = event.content.tryGetMap<String, dynamic>('m.mentions');
|
|
final userIds = mentions?.tryGetList<String>('user_ids') ?? [];
|
|
final roomMention = mentions?.tryGet<bool>('room') ?? false;
|
|
```
|
|
|
|
---
|
|
|
|
## Timeline-Widget Pattern (Flutter)
|
|
|
|
```dart
|
|
class ChatView extends ConsumerStatefulWidget {
|
|
final String roomId;
|
|
const ChatView({required this.roomId, super.key});
|
|
@override
|
|
ConsumerState<ChatView> createState() => _ChatViewState();
|
|
}
|
|
|
|
class _ChatViewState extends ConsumerState<ChatView> {
|
|
Timeline? _timeline;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadTimeline();
|
|
}
|
|
|
|
Future<void> _loadTimeline() async {
|
|
final client = await ref.read(matrixClientProvider.future);
|
|
final room = client.getRoomById(widget.roomId)!;
|
|
_timeline = await room.getTimeline(
|
|
onUpdate: () => setState(() {}),
|
|
);
|
|
setState(() {});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timeline?.cancelSubscriptions(); // WICHTIG!
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final events = _timeline?.events ?? [];
|
|
return ListView.builder(
|
|
reverse: true, // Neueste unten
|
|
itemCount: events.length,
|
|
itemBuilder: (ctx, i) {
|
|
final event = events[i];
|
|
if (event.type != EventTypes.Message) return const SizedBox();
|
|
return ListTile(
|
|
title: Text(event.body),
|
|
subtitle: Text(event.senderId),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Raum-Liste Widget Pattern
|
|
|
|
```dart
|
|
// StreamProvider für reaktive Raumliste
|
|
final roomsProvider = StreamProvider<List<Room>>((ref) async* {
|
|
final client = await ref.watch(matrixClientProvider.future);
|
|
yield _sortedRooms(client);
|
|
await for (final _ in client.onSync.stream) {
|
|
yield _sortedRooms(client);
|
|
}
|
|
});
|
|
|
|
List<Room> _sortedRooms(Client client) => client.rooms
|
|
.where((r) => r.membership == Membership.join)
|
|
.toList()
|
|
..sort((a, b) =>
|
|
(b.lastEvent?.originServerTs ?? DateTime(0))
|
|
.compareTo(a.lastEvent?.originServerTs ?? DateTime(0)));
|
|
|
|
// In Widget:
|
|
final rooms = ref.watch(roomsProvider);
|
|
rooms.when(
|
|
data: (list) => ListView.builder(
|
|
itemCount: list.length,
|
|
itemBuilder: (ctx, i) {
|
|
final room = list[i];
|
|
return ListTile(
|
|
leading: CircleAvatar(child: Text(room.name[0])),
|
|
title: Text(room.getLocalizedDisplayname()),
|
|
subtitle: Text(room.lastEvent?.body ?? ''),
|
|
trailing: room.notificationCount > 0
|
|
? Badge(label: Text('${room.notificationCount}'))
|
|
: null,
|
|
);
|
|
},
|
|
),
|
|
loading: () => const CircularProgressIndicator(),
|
|
error: (e, s) => Text('Fehler: $e'),
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## Nachrichten senden mit optimistischem UI
|
|
|
|
```dart
|
|
// Das SDK fügt das Event sofort mit Status 'sending' in die Timeline ein.
|
|
// UI kann es sofort anzeigen, ohne auf Server zu warten.
|
|
|
|
class MessageInput extends ConsumerWidget {
|
|
final Room room;
|
|
const MessageInput({required this.room, super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final controller = TextEditingController();
|
|
return Row(
|
|
children: [
|
|
Expanded(child: TextField(controller: controller)),
|
|
IconButton(
|
|
icon: const Icon(Icons.send),
|
|
onPressed: () async {
|
|
final text = controller.text.trim();
|
|
if (text.isEmpty) return;
|
|
controller.clear();
|
|
try {
|
|
await room.sendTextEvent(text);
|
|
} catch (e) {
|
|
// Event bleibt mit Status 'error' in Timeline
|
|
// User kann auf "Erneut senden" tippen
|
|
}
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Nützliche Client-Extensions & Helpers
|
|
|
|
```dart
|
|
// User-Erwähnung formatieren
|
|
'@${userId.split(':').first.replaceFirst('@', '')}' // kurzform ohne Server
|
|
|
|
// Raum-Display-Name (FluffyChat-Stil)
|
|
// Für DMs: Name des Gegenübers
|
|
// Für Räume mit Name: Name
|
|
// Für Räume ohne Name: kommagetrennte Mitgliedernamen
|
|
room.getLocalizedDisplayname()
|
|
|
|
// Avatar-URL sicher holen
|
|
user.avatarUrl?.getThumbnail(client, width: 56, height: 56)
|
|
|
|
// Event-Zeitstempel formatieren
|
|
event.originServerTs.toLocal().toString()
|
|
// oder mit intl:
|
|
DateFormat.Hm().format(event.originServerTs.toLocal())
|
|
|
|
// Stable Notification-ID aus Raum-ID (deterministisch)
|
|
int stableId(String roomId) {
|
|
var h = 0;
|
|
for (final c in roomId.codeUnits) {
|
|
h = ((h * 31) + c) & 0x7FFFFFFF;
|
|
}
|
|
return h == 0 ? 1 : h;
|
|
}
|
|
|
|
// Matrix-Größenangabe
|
|
// event.sizeString → '2.3 MB', '512 KB'
|
|
|
|
// mxc:// URI in HTTP umwandeln
|
|
mxcUri.getDownloadUri(client)
|
|
mxcUri.getThumbnail(client, width: 300, height: 300)
|
|
```
|
|
|
|
---
|
|
|
|
## Fehler-Handling
|
|
|
|
```dart
|
|
// MatrixException — Fehler vom Server
|
|
try {
|
|
await client.joinRoom('!room:server');
|
|
} on MatrixException catch (e) {
|
|
switch (e.error) {
|
|
case MatrixError.M_FORBIDDEN: // Kein Zugang
|
|
case MatrixError.M_NOT_FOUND: // Raum existiert nicht
|
|
case MatrixError.M_LIMIT_EXCEEDED: // Rate-Limit
|
|
final retryAfter = e.retryAfterMs; // Wann wieder versuchen
|
|
case MatrixError.M_UNKNOWN:
|
|
default:
|
|
print(e.errorMessage);
|
|
}
|
|
}
|
|
|
|
// TimeoutException — Keine Antwort vom Server
|
|
// SocketException — Keine Netzwerkverbindung
|
|
// FormatException — Ungültige Server-Antwort
|
|
|
|
// Verbindungsstatus überwachen
|
|
client.onConnectionStateChanged.stream.listen((state) {
|
|
switch (state) {
|
|
case ConnectionState.connected: // Sync läuft
|
|
case ConnectionState.connecting: // Verbindet
|
|
case ConnectionState.reconnecting: // Verbindung wiederhergestellt
|
|
}
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Wichtige Pakete für Pyramid
|
|
|
|
```yaml
|
|
dependencies:
|
|
matrix: ^6.x.x # Matrix SDK (Haupt-SDK)
|
|
flutter_vodozemac: ^x.x.x # E2EE (Rust-basiert, empfohlen)
|
|
firebase_messaging: ^x.x.x # FCM Push (Android)
|
|
firebase_core: ^x.x.x
|
|
flutter_local_notifications: ^x.x.x # Lokale Notifications
|
|
flutter_riverpod: ^x.x.x # State Management
|
|
sqflite: ^x.x.x # Datenbank
|
|
path_provider: ^x.x.x # App-Verzeichnisse
|
|
shared_preferences: ^x.x.x # Einfache KV-Speicherung
|
|
livekit_client: ^x.x.x # LiveKit für VoIP
|
|
flutter_webrtc: ^x.x.x # WebRTC
|
|
go_router: ^x.x.x # Navigation
|
|
local_notifier: ^x.x.x # Windows Notifications
|
|
windows_taskbar: ^x.x.x # Windows Badge
|
|
```
|