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 createState() => _NewDmDialogState(); } class _NewDmDialogState extends ConsumerState { final _searchCtrl = TextEditingController(); List _results = []; bool _searching = false; String? _error; Timer? _debounce; final Set _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 _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 _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))), ]), ); }