Files
pyramid/lib/widgets/new_dm_dialog.dart
T
Bernd Steckmeister 25ed765a03 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
2026-07-03 05:47:18 +02:00

347 lines
11 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/mxc_image.dart';
class NewDmDialog extends ConsumerStatefulWidget {
const NewDmDialog({super.key});
@override
ConsumerState<NewDmDialog> createState() => _NewDmDialogState();
}
class _NewDmDialogState extends ConsumerState<NewDmDialog> {
final _searchCtrl = TextEditingController();
List<Profile> _results = [];
bool _searching = false;
String? _error;
Timer? _debounce;
final Set<String> _starting = {};
@override
void dispose() {
_searchCtrl.dispose();
_debounce?.cancel();
super.dispose();
}
void _onSearchChanged(String query) {
_debounce?.cancel();
if (query.trim().length < 2) {
setState(() {
_results = [];
_searching = false;
_error = null;
});
return;
}
setState(() => _searching = true);
_debounce = Timer(const Duration(milliseconds: 500), () => _search(query.trim()));
}
Future<void> _search(String query) async {
try {
final client = await ref.read(matrixClientProvider.future);
final res = await client.searchUserDirectory(query, limit: 20);
if (mounted) {
setState(() {
_results = res.results;
_searching = false;
_error = null;
});
}
} catch (e) {
if (mounted) {
setState(() {
_searching = false;
_error = e.toString().split('\n').first;
});
}
}
}
Future<void> _startDm(String userId) async {
setState(() => _starting.add(userId));
try {
final client = await ref.read(matrixClientProvider.future);
final roomId = await client.startDirectChat(userId);
if (mounted) {
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
setState(() {
_starting.remove(userId);
_error = e.toString().split('\n').first;
});
}
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final size = MediaQuery.sizeOf(context);
final isNarrow = size.width < 600;
return Dialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isNarrow ? size.width * 0.97 : 480,
maxHeight: size.height * 0.75,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 12, 12),
child: Row(children: [
Icon(Icons.chat_bubble_outline_rounded, size: 16, color: pt.accent),
const SizedBox(width: 8),
Expanded(
child: Text('Neue Unterhaltung',
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight: FontWeight.w600)),
),
IconButton(
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
),
]),
),
Divider(color: pt.border, height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: TextField(
controller: _searchCtrl,
autofocus: true,
style: TextStyle(color: pt.fg, fontSize: 13),
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Name oder @nutzer:server suchen',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 10, right: 8),
child: Icon(Icons.search_rounded, size: 16, color: pt.fgDim),
),
prefixIconConstraints:
const BoxConstraints(minWidth: 0, minHeight: 0),
filled: true,
fillColor: pt.bg2,
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.accent)),
),
cursorColor: pt.accent,
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: _DmErrorBanner(text: _error!, pt: pt),
),
Flexible(
child: _searching
? const Padding(
padding: EdgeInsets.all(24),
child:
Center(child: CircularProgressIndicator.adaptive()),
)
: _results.isEmpty
? Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
_searchCtrl.text.trim().length < 2
? 'Mindestens 2 Zeichen eingeben.'
: 'Keine Nutzer gefunden.',
style: TextStyle(
color: pt.fgMuted, fontSize: 13),
),
),
)
: ListView.builder(
shrinkWrap: true,
padding:
const EdgeInsets.fromLTRB(8, 0, 8, 12),
itemCount: _results.length,
itemBuilder: (ctx, i) {
final user = _results[i];
return _UserResultItem(
profile: user,
pt: pt,
isStarting:
_starting.contains(user.userId),
onTap: () => _startDm(user.userId),
);
},
),
),
],
),
),
);
}
}
class _UserResultItem extends ConsumerWidget {
final Profile profile;
final PyramidTheme pt;
final bool isStarting;
final VoidCallback onTap;
const _UserResultItem({
required this.profile,
required this.pt,
required this.isStarting,
required this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(matrixClientProvider).valueOrNull;
final name = profile.displayName ?? profile.userId;
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
Widget avatar;
if (client != null && profile.avatarUrl != null) {
avatar = MxcAvatar(
mxcUri: profile.avatarUrl,
client: client,
size: 36,
borderRadius: BorderRadius.circular(18),
placeholder: (_) =>
_DmInitialCircle(initial: initial, size: 36, name: name),
);
} else {
avatar = _DmInitialCircle(initial: initial, size: 36, name: name);
}
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: isStarting ? null : onTap,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(children: [
avatar,
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
Text(profile.userId,
style:
TextStyle(color: pt.fgDim, fontSize: 11),
overflow: TextOverflow.ellipsis),
]),
),
const SizedBox(width: 8),
if (isStarting)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2))
else
Icon(Icons.arrow_forward_rounded,
size: 14, color: pt.fgDim),
]),
),
),
);
}
}
class _DmInitialCircle extends StatelessWidget {
final String initial;
final double size;
final String name;
const _DmInitialCircle(
{required this.initial, required this.size, required this.name});
Color _color() {
const colors = [
Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981),
Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B),
];
if (name.isEmpty) return colors[0];
return colors[name.codeUnitAt(0) % colors.length];
}
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(color: _color(), shape: BoxShape.circle),
child: Center(
child: Text(initial,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600)),
),
);
}
}
class _DmErrorBanner extends StatelessWidget {
final String text;
final PyramidTheme pt;
const _DmErrorBanner({required this.text, required this.pt});
@override
Widget build(BuildContext context) => Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: pt.danger.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: pt.danger.withAlpha(60)),
),
child: Row(children: [
Icon(Icons.error_outline_rounded, size: 14, color: pt.danger),
const SizedBox(width: 8),
Expanded(
child: Text(text,
style:
TextStyle(color: pt.danger, fontSize: 12))),
]),
);
}