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
347 lines
11 KiB
Dart
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))),
|
|
]),
|
|
);
|
|
}
|