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