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:
Bernd Steckmeister
2026-07-03 05:47:18 +02:00
parent d706ace35f
commit 25ed765a03
195 changed files with 47784 additions and 1236 deletions
+184
View File
@@ -0,0 +1,184 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:pyramid/core/theme.dart';
class BannerCropDialog extends StatefulWidget {
final Uint8List imageBytes;
// Width-to-height ratio for the crop frame (e.g. 2.4 = 2.4× wider than tall)
final double aspectRatio;
const BannerCropDialog({
super.key,
required this.imageBytes,
this.aspectRatio = 2.4,
});
@override
State<BannerCropDialog> createState() => _BannerCropDialogState();
}
class _BannerCropDialogState extends State<BannerCropDialog> {
final _transformController = TransformationController();
final _repaintKey = GlobalKey();
bool _exporting = false;
@override
void dispose() {
_transformController.dispose();
super.dispose();
}
Future<void> _confirm() async {
setState(() => _exporting = true);
try {
final boundary = _repaintKey.currentContext!.findRenderObject()
as RenderRepaintBoundary;
// pixelRatio 2 gives enough resolution for a banner
final image = await boundary.toImage(pixelRatio: 2.0);
final byteData =
await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null || !mounted) return;
Navigator.of(context).pop(byteData.buffer.asUint8List());
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler beim Zuschneiden: $e')));
setState(() => _exporting = false);
}
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return Dialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rLg)),
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Header ────────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 12, 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Banner zuschneiden',
style: TextStyle(
color: pt.fg,
fontSize: 16,
fontWeight: FontWeight.w700)),
Text(
'Ziehe und zoome das Bild zum gewünschten Ausschnitt',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
IconButton(
icon: Icon(Icons.close, color: pt.fgDim, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// ── Crop frame ────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: AspectRatio(
aspectRatio: widget.aspectRatio,
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rBase),
child: RepaintBoundary(
key: _repaintKey,
child: Container(
color: Colors.black,
child: InteractiveViewer(
transformationController: _transformController,
minScale: 0.25,
maxScale: 8.0,
child: Image.memory(
widget.imageBytes,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
),
),
),
),
),
),
),
// ── Hint ──────────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
child: Row(
children: [
Icon(Icons.info_outline, size: 13, color: pt.fgDim),
const SizedBox(width: 6),
Expanded(
child: Text(
'Pinch/Scroll zum Zoomen, Drag zum Positionieren',
style: TextStyle(color: pt.fgDim, fontSize: 11),
),
),
TextButton(
onPressed: () {
_transformController.value = Matrix4.identity();
},
child:
Text('Zurücksetzen', style: TextStyle(color: pt.fgDim, fontSize: 11)),
),
],
),
),
// ── Actions ───────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _exporting
? null
: () => Navigator.of(context).pop(),
child: const Text('Abbrechen'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _exporting ? null : _confirm,
style: FilledButton.styleFrom(
backgroundColor: pt.accent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase)),
),
child: _exporting
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: pt.bg0))
: const Text('Übernehmen',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600)),
),
],
),
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
// Simple hover-aware widget for desktop hover effects
class HoverRegion extends StatefulWidget {
final Widget Function(BuildContext context, bool hovered) builder;
final MouseCursor cursor;
const HoverRegion({
super.key,
required this.builder,
this.cursor = SystemMouseCursors.click,
});
@override
State<HoverRegion> createState() => _HoverRegionState();
}
class _HoverRegionState extends State<HoverRegion> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: widget.cursor,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: widget.builder(context, _hovered),
);
}
}
// Icon button styled to match Pyramid's .icon-btn
class PyrIconBtn extends StatefulWidget {
final Widget icon;
final VoidCallback? onPressed;
final String? tooltip;
final bool active;
final Color? activeColor;
final Color? dangerColor;
final double size;
const PyrIconBtn({
super.key,
required this.icon,
this.onPressed,
this.tooltip,
this.active = false,
this.activeColor,
this.dangerColor,
this.size = 32,
});
@override
State<PyrIconBtn> createState() => _PyrIconBtnState();
}
class _PyrIconBtnState extends State<PyrIconBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final fgMuted = isDark ? const Color(0xFFA4A4B0) : const Color(0xFF54545E);
final fg = isDark ? const Color(0xFFECECF0) : const Color(0xFF1A1A1F);
final bgHover = isDark ? const Color(0xFF232330) : const Color(0xFFE9E7E1);
final color = widget.active
? (widget.activeColor ?? const Color(0xFFF5A614))
: _hovered ? fg : fgMuted;
final platform = Theme.of(context).platform;
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS;
// On mobile, use a slightly larger touch target, but respect the requested size
final touchSize = isMobile ? (widget.size + 8).clamp(32.0, 48.0) : widget.size;
final btn = MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onPressed,
child: SizedBox(
width: touchSize,
height: touchSize,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: _hovered ? bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(7),
),
child: Center(
child: IconTheme(
data: IconThemeData(color: color, size: 16),
child: widget.icon,
),
),
),
),
),
),
);
if (widget.tooltip != null) {
return Tooltip(
message: widget.tooltip!,
waitDuration: const Duration(milliseconds: 500),
child: btn,
);
}
return btn;
}
}
+184 -32
View File
@@ -1,53 +1,205 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/media_cache.dart';
class MxcImage extends ConsumerWidget {
class MxcAvatar extends StatefulWidget {
final Uri? mxcUri;
final Client client;
final double size;
final BorderRadius? borderRadius;
final Widget Function(BuildContext) placeholder;
const MxcAvatar({
super.key,
required this.mxcUri,
required this.client,
required this.size,
required this.placeholder,
this.borderRadius,
});
@override
State<MxcAvatar> createState() => _MxcAvatarState();
}
class _MxcAvatarState extends State<MxcAvatar> {
late Future<Uint8List?> _future;
@override
void initState() {
super.initState();
_future = _load();
}
@override
void didUpdateWidget(MxcAvatar old) {
super.didUpdateWidget(old);
if (old.mxcUri != widget.mxcUri) _future = _load();
}
Future<Uint8List?> _load() async {
final mxc = widget.mxcUri;
if (mxc == null) return null;
final key = 'avatar:${mxc}_${widget.size.round()}';
// Memory → Disk → Netzwerk. Disk-Treffer machen Avatare nach einem
// App-Neustart sofort sichtbar statt nachzuladen.
final hit = await MediaCache.instance.getPersistent(key);
if (hit != null) return hit;
final headers = {'authorization': 'Bearer ${widget.client.accessToken}'};
try {
// Try thumbnail first; fall back to full download if 404 (server
// doesn't support on-the-fly thumbnailing, e.g. Continuwuity).
final thumbUri = await mxc.getThumbnailUri(
widget.client,
width: widget.size.round(),
height: widget.size.round(),
method: ThumbnailMethod.crop,
);
final thumbResp = await widget.client.httpClient.get(thumbUri, headers: headers);
if (thumbResp.statusCode == 200) {
await MediaCache.instance.putPersistent(key, thumbResp.bodyBytes);
return thumbResp.bodyBytes;
}
// Thumbnail not available — download full image.
final dlUri = await mxc.getDownloadUri(widget.client);
final dlResp = await widget.client.httpClient.get(dlUri, headers: headers);
if (dlResp.statusCode != 200) return null;
await MediaCache.instance.putPersistent(key, dlResp.bodyBytes);
return dlResp.bodyBytes;
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List?>(
future: _future,
builder: (context, snap) {
if (snap.hasData && snap.data != null) {
return ClipRRect(
borderRadius: widget.borderRadius ??
BorderRadius.circular(widget.size / 2),
child: Image.memory(
snap.data!,
width: widget.size,
height: widget.size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => widget.placeholder(context),
),
);
}
return widget.placeholder(context);
},
);
}
}
// Rectangular MXC image (no fixed size, fills parent)
class MxcImage extends StatefulWidget {
final String mxcUri;
final Client client;
final BoxFit fit;
final double? width;
final double? height;
final Widget Function(BuildContext)? placeholder;
final Widget? placeholder;
const MxcImage({
super.key,
required this.mxcUri,
required this.client,
this.fit = BoxFit.cover,
this.width,
this.height,
this.placeholder,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final clientAsync = ref.watch(matrixClientProvider);
final uri = mxcUri;
State<MxcImage> createState() => _MxcImageState();
}
if (uri == null) return _fallback(context);
class _MxcImageState extends State<MxcImage> {
late Future<Uint8List?> _future;
return clientAsync.when(
loading: () => _fallback(context),
error: (e, st) => _fallback(context),
data: (client) => FutureBuilder<Uri>(
future: uri.getThumbnailUri(
client,
width: width != null ? (width! * 2).toInt() : 64,
height: height != null ? (height! * 2).toInt() : 64,
method: ThumbnailMethod.crop,
),
builder: (context, snap) {
if (!snap.hasData) return _fallback(context);
return Image.network(
snap.data!.toString(),
width: width,
height: height,
fit: BoxFit.cover,
errorBuilder: (ctx, err, st) => _fallback(context),
);
},
),
);
@override
void initState() {
super.initState();
_future = _load();
}
Widget _fallback(BuildContext context) =>
placeholder?.call(context) ?? SizedBox(width: width, height: height);
@override
void didUpdateWidget(MxcImage old) {
super.didUpdateWidget(old);
if (old.mxcUri != widget.mxcUri) {
// Block-Body: die Closure darf KEIN Future zurückgeben, sonst wirft
// Flutter "setState() callback returned a Future".
setState(() {
_future = _load();
});
}
}
Future<Uint8List?> _load() async {
final key = 'img:${widget.mxcUri}';
final hit = await MediaCache.instance.getPersistent(key);
if (hit != null) return hit;
try {
final uri = Uri.parse(widget.mxcUri);
final headers = {
'authorization': 'Bearer ${widget.client.accessToken}',
};
// Erst Thumbnail versuchen (spart Bandbreite, v.a. mobil) — Fallback
// auf Volldownload für Server ohne Thumbnail-Support (Continuwuity).
try {
final thumbUri = await uri.getThumbnailUri(
widget.client,
width: 800,
height: 600,
method: ThumbnailMethod.scale,
);
final res = await widget.client.httpClient.get(thumbUri, headers: headers);
if (res.statusCode == 200) {
await MediaCache.instance.putPersistent(key, res.bodyBytes);
return res.bodyBytes;
}
} catch (_) {}
final httpUri = await uri.getDownloadUri(widget.client);
final res = await widget.client.httpClient.get(httpUri, headers: headers);
if (res.statusCode != 200) return null;
final bytes = res.bodyBytes;
await MediaCache.instance.putPersistent(key, bytes);
return bytes;
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List?>(
future: _future,
builder: (_, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return widget.placeholder ?? const SizedBox.shrink();
}
if (snap.hasData && snap.data != null) {
return Image.memory(
snap.data!,
fit: widget.fit,
width: widget.width,
height: widget.height,
gaplessPlayback: true,
errorBuilder: (_, __, ___) =>
widget.placeholder ?? const SizedBox.shrink(),
);
}
return widget.placeholder ?? const SizedBox.shrink();
},
);
}
}
+346
View File
@@ -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))),
]),
);
}
+442
View File
@@ -0,0 +1,442 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/mxc_image.dart';
class ProfilePopover extends StatefulWidget {
final String userId;
final Room room;
final Offset globalPos;
final VoidCallback onClose;
const ProfilePopover({
super.key,
required this.userId,
required this.room,
required this.globalPos,
required this.onClose,
});
@override
State<ProfilePopover> createState() => _ProfilePopoverState();
}
class _ProfilePopoverState extends State<ProfilePopover> {
Profile? _profile;
PresenceType? _presence;
String? _bannerMxcUri;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final p = await widget.room.client.getProfileFromUserId(widget.userId);
if (mounted) setState(() => _profile = p);
} catch (_) {}
try {
final p = await widget.room.client.fetchCurrentPresence(widget.userId);
// Veraltete "online"-Presence herabstufen, wenn die letzte Aktivität
// nicht frisch ist (Server meldet sonst dauerhaft online).
final last = p.lastActiveTimestamp;
final fresh = p.currentlyActive == true ||
(last != null &&
DateTime.now().difference(last) < const Duration(minutes: 5));
final effective = (p.presence == PresenceType.online && !fresh)
? PresenceType.offline
: p.presence;
if (mounted) setState(() => _presence = effective);
} catch (_) {}
// Load banner from Matrix profile custom field (works for any user)
try {
final client = widget.room.client;
final apiUri = client.homeserver!.replace(
path: '/_matrix/client/v3/profile/${Uri.encodeComponent(widget.userId)}',
);
final resp = await client.httpClient.get(
apiUri,
headers: {'Authorization': 'Bearer ${client.accessToken}'},
);
if (resp.statusCode == 200) {
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final url = data['io.pyramid.profile.banner_url'] as String?;
if (url != null && mounted) setState(() => _bannerMxcUri = url);
}
} catch (_) {}
// Legacy fallback: account data (only own user)
if (_bannerMxcUri == null && widget.userId == widget.room.client.userID) {
try {
final bannerData =
widget.room.client.accountData['com.pyramid.profile.banner'];
final url = bannerData?.content['url'] as String?;
if (url != null && mounted) setState(() => _bannerMxcUri = url);
} catch (_) {}
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final member = widget.room.unsafeGetUserFromMemoryOrFallback(widget.userId);
final displayName = _profile?.displayName ?? member.displayName ?? widget.userId.split(':').first.replaceAll('@', '');
final avatarUrl = _profile?.avatarUrl ?? member.avatarUrl;
final handle = widget.userId;
final initials = displayName.isNotEmpty ? displayName[0].toUpperCase() : '?';
final color = _colorForName(displayName);
final powerLevel = widget.room.getPowerLevelByUserId(widget.userId);
final joinDate = _formatJoinDate(widget.room, widget.userId);
const popoverW = 300.0;
const popoverH = 380.0;
final left = (widget.globalPos.dx + 16).clamp(8.0, size.width - popoverW - 8);
final top = (widget.globalPos.dy - 80).clamp(8.0, size.height - popoverH - 8);
return Positioned(
left: left,
top: top,
child: Material(
color: Colors.transparent,
child: Container(
width: popoverW,
decoration: BoxDecoration(
color: PyramidColors.bg0,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: PyramidColors.border),
boxShadow: const [
BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8))
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Banner — real image for own profile, color gradient for others
SizedBox(
height: 64,
child: _bannerMxcUri != null
? MxcImage(
mxcUri: _bannerMxcUri!,
client: widget.room.client,
fit: BoxFit.cover,
width: double.infinity,
height: 64,
placeholder: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
color.withValues(alpha: 0.6),
color.withValues(alpha: 0.3)
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
)
: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
color.withValues(alpha: 0.6),
color.withValues(alpha: 0.3)
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar row
Transform.translate(
offset: const Offset(0, -28),
child: Row(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: PyramidColors.bg0, width: 3),
),
clipBehavior: Clip.antiAlias,
child: avatarUrl != null
? MxcImage(
mxcUri: avatarUrl.toString(),
client: widget.room.client,
width: 56,
height: 56,
placeholder: Center(
child: Text(initials,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w700)),
),
)
: Center(
child: Text(initials,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w700))),
),
Positioned(
right: -2,
bottom: -2,
child: _PresenceDot(presence: _presence, size: 14),
),
],
),
],
),
),
Transform.translate(
offset: const Offset(0, -20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(displayName,
style: const TextStyle(
color: PyramidColors.fg,
fontSize: 17,
fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(handle,
style: const TextStyle(
color: PyramidColors.fgMuted, fontSize: 12)),
const SizedBox(height: 12),
// Action buttons
Row(
children: [
_ProfileBtn(
label: 'Nachricht',
icon: Icons.send_outlined,
primary: true,
onTap: () {
widget.onClose();
// TODO: open DM
},
),
const SizedBox(width: 8),
_ProfileBtn(
label: 'Anrufen',
icon: Icons.call_outlined,
onTap: () {},
),
const SizedBox(width: 8),
_ProfileBtn(
icon: Icons.more_vert,
onTap: () {},
),
],
),
if (powerLevel > 0) ...[
const SizedBox(height: 14),
const Text('Rollen',
style: TextStyle(
color: PyramidColors.fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.8)),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_RoleBadge(
label: powerLevel >= 100 ? 'Admin' : 'Moderator',
color: powerLevel >= 100
? const Color(0xFFF59E0B)
: const Color(0xFF06B6D4),
),
],
),
],
if (joinDate != null) ...[
const SizedBox(height: 14),
const Text('Mitglied seit',
style: TextStyle(
color: PyramidColors.fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.8)),
const SizedBox(height: 4),
Text(joinDate,
style: const TextStyle(
color: PyramidColors.fgMuted, fontSize: 12)),
],
],
),
),
],
),
),
],
),
),
),
),
);
}
String? _formatJoinDate(Room room, String userId) {
try {
final member = room.unsafeGetUserFromMemoryOrFallback(userId);
// User joined if membership is join
if (member.membership != Membership.join) return null;
// No timestamp available from StrippedStateEvent; show server domain hint
final server = userId.contains(':') ? userId.split(':').last : null;
return server != null ? 'via $server' : null;
} catch (_) {
return null;
}
}
Color _colorForName(String name) {
const colors = [
Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFFFF6B9D),
Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFF3B82F6),
Color(0xFFF43F5E),
];
final hash = name.codeUnits.fold(0, (a, b) => a + b);
return colors[hash % colors.length];
}
}
class _PresenceDot extends StatelessWidget {
final PresenceType? presence;
final double size;
const _PresenceDot({required this.presence, required this.size});
@override
Widget build(BuildContext context) {
final color = switch (presence) {
PresenceType.online => const Color(0xFF23A55A),
PresenceType.unavailable => const Color(0xFFF0B232),
_ => const Color(0xFF7C7C8A),
};
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: PyramidColors.bg0, width: 2),
),
);
}
}
class _ProfileBtn extends StatefulWidget {
final String? label;
final IconData icon;
final bool primary;
final VoidCallback onTap;
const _ProfileBtn({
required this.icon,
required this.onTap,
this.label,
this.primary = false,
});
@override
State<_ProfileBtn> createState() => _ProfileBtnState();
}
class _ProfileBtnState extends State<_ProfileBtn> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final hasLabel = widget.label != null;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: EdgeInsets.symmetric(
horizontal: hasLabel ? 12 : 9, vertical: 7),
decoration: BoxDecoration(
color: widget.primary
? (_hover
? PyramidTheme.of(context).accent.withAlpha(200)
: PyramidTheme.of(context).accent)
: (_hover ? PyramidColors.bgHover : PyramidColors.bg2),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: widget.primary ? Colors.transparent : PyramidColors.border,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(widget.icon,
size: 14,
color: widget.primary
? PyramidColors.accentFg
: PyramidColors.fg),
if (hasLabel) ...[
const SizedBox(width: 6),
Text(widget.label!,
style: TextStyle(
color: widget.primary
? PyramidColors.accentFg
: PyramidColors.fg,
fontSize: 13,
fontWeight: FontWeight.w500)),
],
],
),
),
),
);
}
}
class _RoleBadge extends StatelessWidget {
final String label;
final Color color;
const _RoleBadge({required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6, height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 5),
Text(label,
style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w500)),
],
),
);
}
}
+202
View File
@@ -0,0 +1,202 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
import 'package:pyramid/core/theme.dart';
class PyramidLoader extends StatefulWidget {
final double size;
final bool useLottie;
const PyramidLoader({super.key, this.size = 60, this.useLottie = false});
@override
State<PyramidLoader> createState() => _PyramidLoaderState();
}
class _PyramidLoaderState extends State<PyramidLoader>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
// Mirror the JS constants exactly
static const _hold = 0.2;
static const _curveExp = 3.0;
static const _tiltDeg = 20.0;
double _ease(double t, double exp) =>
t < 0.5 ? 0.5 * pow(2 * t, exp) : 1 - 0.5 * pow(2 * (1 - t), exp);
double _yawAt(double t) {
final a = 1.0 - _hold;
if (t >= a) return pi / 4;
return _ease(t / a, _curveExp) * 2 * pi + pi / 4;
}
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
)..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
Color _accent(BuildContext ctx) {
try {
return PyramidTheme.of(ctx).accent;
} catch (_) {
return const Color(0xFFF5A524);
}
}
@override
Widget build(BuildContext context) {
final accent = _accent(context);
final painter = AnimatedBuilder(
animation: _ctrl,
builder: (_, __) => CustomPaint(
painter: _PyramidPainter(
yaw: _yawAt(_ctrl.value),
accent: accent,
tiltDeg: _tiltDeg,
),
size: Size(widget.size, widget.size),
),
);
if (widget.useLottie) {
return Center(
child: SizedBox(
width: widget.size,
height: widget.size,
child: ColorFiltered(
colorFilter: ColorFilter.mode(accent, BlendMode.srcIn),
child: Lottie.asset(
'assets/pyramid-loader.json',
fit: BoxFit.contain,
repeat: true,
animate: true,
errorBuilder: (_, __, ___) => painter,
),
),
),
);
}
return Center(child: painter);
}
}
class _PyramidPainter extends CustomPainter {
final double yaw;
final Color accent;
final double tiltDeg;
const _PyramidPainter({
required this.yaw,
required this.accent,
required this.tiltDeg,
});
List<double> _rotY(List<double> v, double a) {
final c = cos(a), s = sin(a);
return [v[0] * c + v[2] * s, v[1], -v[0] * s + v[2] * c];
}
List<List<double>> _clipZ(List<List<double>> poly) {
final out = <List<double>>[];
for (var i = 0; i < poly.length; i++) {
final a = poly[i];
final b = poly[(i + 1) % poly.length];
final ai = a[2] >= 0, bi = b[2] >= 0;
if (ai) out.add(a);
if (ai != bi) {
final t = a[2] / (a[2] - b[2]);
out.add([a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]), 0.0]);
}
}
return out;
}
Offset _proj(List<double> v, double sc, double cx, double cy, double tr) =>
Offset(cx + v[0] * sc, cy + (v[1] * cos(tr) + v[2] * sin(tr)) * sc);
double _frontness(List<List<double>> verts) {
final a = verts[0], b = verts[1], c = verts[2];
final ux = b[0]-a[0], uy = b[1]-a[1], uz = b[2]-a[2];
final vx = c[0]-a[0], vy = c[1]-a[1], vz = c[2]-a[2];
final nz = ux * vy - uy * vx;
final nLen = sqrt(
pow(uy * vz - uz * vy, 2) + pow(uz * vx - ux * vz, 2) + nz * nz,
);
return nLen == 0 ? 0 : max(0.0, -nz / nLen);
}
Color _darken(Color c, double amount) => Color.fromARGB(
c.alpha,
max(0, (c.red * (1 - amount)).round()),
max(0, (c.green * (1 - amount)).round()),
max(0, (c.blue * (1 - amount)).round()),
);
@override
void paint(Canvas canvas, Size size) {
final sc = size.width * 0.38;
final cx = size.width / 2;
final cy = size.height / 2;
final tr = -tiltDeg * pi / 180;
const base = 0.7;
final faces = [
[[0.0, -1.0, 0.0], [base, 1.0, base], [base, 1.0, -base]],
[[0.0, -1.0, 0.0], [base, 1.0, -base], [-base, 1.0, -base]],
[[0.0, -1.0, 0.0], [-base, 1.0, -base],[-base, 1.0, base]],
[[0.0, -1.0, 0.0], [-base, 1.0, base], [base, 1.0, base]],
];
final items = <({List<Offset> pts, double depth})>[];
for (final face in faces) {
final rot = face.map((v) => _rotY(v, yaw)).toList();
final cl = _clipZ(rot);
if (cl.length < 3) continue;
final depth = cl.fold(0.0, (s, v) => s + v[2]) / cl.length;
items.add((
pts: cl.map((v) => _proj(v, sc, cx, cy, tr)).toList(),
depth: depth,
));
}
items.sort((a, b) => a.depth.compareTo(b.depth));
final fillPaint = Paint()
..color = _darken(accent, 0.45)
..style = PaintingStyle.fill;
final strokePaint = Paint()
..color = accent
..style = PaintingStyle.stroke
..strokeWidth = 1.4 * (size.width / 96)
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round;
for (final item in items) {
final path = Path()..moveTo(item.pts[0].dx, item.pts[0].dy);
for (var i = 1; i < item.pts.length; i++) {
path.lineTo(item.pts[i].dx, item.pts[i].dy);
}
path.close();
canvas.drawPath(path, fillPaint);
canvas.drawPath(path, strokePaint);
}
}
@override
bool shouldRepaint(_PyramidPainter old) =>
old.yaw != yaw || old.accent != accent;
}
+187
View File
@@ -0,0 +1,187 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
// 3D Pyramid logo — two faces + base outline + spine
class PyramidLogo extends StatelessWidget {
final double size;
final Color color;
const PyramidLogo({super.key, this.size = 36, this.color = Colors.white});
@override
Widget build(BuildContext context) =>
CustomPaint(size: Size(size, size), painter: _PyramidLogoPainter(color: color));
}
class _PyramidLogoPainter extends CustomPainter {
final Color color;
const _PyramidLogoPainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
final sx = w / 48;
final sy = h / 48;
Offset p(double x, double y) => Offset(x * sx, y * sy);
final apex = p(24, 6);
final baseL = p(6, 40);
final baseR = p(42, 40);
final baseM = p(24, 34);
// Left face — full opacity → 55%
final leftPath = Path()..moveTo(apex.dx, apex.dy)..lineTo(baseL.dx, baseL.dy)..lineTo(baseM.dx, baseM.dy)..close();
canvas.drawPath(leftPath, Paint()..shader = ui.Gradient.linear(
Offset(0, apex.dy), Offset(0, baseL.dy),
[color.withAlpha(255), color.withAlpha(140)],
));
// Right face — 85% → 25%
final rightPath = Path()..moveTo(apex.dx, apex.dy)..lineTo(baseR.dx, baseR.dy)..lineTo(baseM.dx, baseM.dy)..close();
canvas.drawPath(rightPath, Paint()..shader = ui.Gradient.linear(
Offset(apex.dx, apex.dy), Offset(baseR.dx, baseR.dy),
[color.withAlpha(217), color.withAlpha(64)],
));
// Base outline
canvas.drawPath(
Path()..moveTo(baseL.dx, baseL.dy)..lineTo(baseM.dx, baseM.dy)..lineTo(baseR.dx, baseR.dy),
Paint()..color = color.withAlpha(102)..style = PaintingStyle.stroke
..strokeWidth = math.max(sx, 0.7)..strokeJoin = StrokeJoin.round,
);
// Spine
canvas.drawLine(apex, baseM, Paint()..color = color.withAlpha(230)
..strokeWidth = math.max(sx * 0.8, 0.6));
}
@override
bool shouldRepaint(_PyramidLogoPainter old) => old.color != color;
}
// Pyramid mark — stylised stacked triangles
class PyramidMark extends StatelessWidget {
final double size;
final Color? color;
const PyramidMark({super.key, this.size = 30, this.color});
@override
Widget build(BuildContext context) {
final c = color ?? Colors.white;
return SizedBox(
width: size,
height: size,
child: CustomPaint(painter: _PyramidPainter(c)),
);
}
}
class _PyramidPainter extends CustomPainter {
final Color color;
_PyramidPainter(this.color);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color..style = PaintingStyle.fill;
final w = size.width;
final h = size.height;
// bottom big triangle
final big = Path()
..moveTo(w * 0.5, h * 0.1)
..lineTo(w * 0.92, h * 0.88)
..lineTo(w * 0.08, h * 0.88)
..close();
canvas.drawPath(big, paint);
// inner cutout (dark) to create layered look
final cut = Paint()
..color = Colors.black.withAlpha(80)
..style = PaintingStyle.fill;
final inner = Path()
..moveTo(w * 0.5, h * 0.28)
..lineTo(w * 0.74, h * 0.72)
..lineTo(w * 0.26, h * 0.72)
..close();
canvas.drawPath(inner, cut);
}
@override
bool shouldRepaint(_PyramidPainter old) => old.color != color;
}
// Pyramid space glyph (letter + triangle overlay)
class PyramidGlyph extends StatelessWidget {
final double size;
final Color color;
final String label;
const PyramidGlyph({
super.key,
this.size = 30,
required this.color,
required this.label,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: Size(size, size),
painter: _PyramidPainter(color),
),
Text(
label.isNotEmpty ? label[0].toUpperCase() : '?',
style: TextStyle(
color: Colors.white,
fontSize: size * 0.38,
fontWeight: FontWeight.bold,
height: 1,
),
),
],
),
);
}
}
// Presence indicator dot
class PresenceDot extends StatelessWidget {
final String status; // online, away, busy, offline
final double size;
final Color borderColor;
const PresenceDot({
super.key,
required this.status,
this.size = 10,
this.borderColor = const Color(0xFF111114),
});
Color get _color => switch (status) {
'online' => const Color(0xFF4ADE80),
'away' => const Color(0xFFFACC15),
'busy' => const Color(0xFFF87171),
_ => const Color(0xFF6F6F7D),
};
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: _color,
shape: BoxShape.circle,
border: Border.all(color: borderColor, width: 2),
),
);
}
}
+233
View File
@@ -0,0 +1,233 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:pyramid/core/theme.dart';
class ScreenSharePicker extends StatefulWidget {
const ScreenSharePicker({super.key});
static Future<DesktopCapturerSource?> show(BuildContext context) async {
return showDialog<DesktopCapturerSource>(
context: context,
builder: (context) => const ScreenSharePicker(),
);
}
@override
State<ScreenSharePicker> createState() => _ScreenSharePickerState();
}
class _ScreenSharePickerState extends State<ScreenSharePicker> with SingleTickerProviderStateMixin {
late TabController _tabController;
List<DesktopCapturerSource> _sources = [];
bool _loading = true;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadSources();
}
Future<void> _loadSources() async {
try {
// FluffyChat Fix: Get both types at once to avoid native crashes on Windows
final sources = await desktopCapturer.getSources(types: [
SourceType.Screen,
SourceType.Window,
]);
if (mounted) {
setState(() {
_sources = sources;
_loading = false;
});
}
} catch (e) {
print('[Picker] Error loading sources: $e');
if (mounted) setState(() => _loading = false);
}
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final screens = _sources.where((s) => s.type == SourceType.Screen).toList();
final windows = _sources.where((s) => s.type == SourceType.Window).toList();
final screenSize = MediaQuery.sizeOf(context);
return Center(
child: Container(
// Auf kleinen Bildschirmen (Mobile, Quer- wie Hochformat) einpassen.
width: (screenSize.width - 32).clamp(280.0, 600.0),
height: (screenSize.height - 48).clamp(320.0, 520.0),
decoration: BoxDecoration(
color: pt.bg1,
borderRadius: BorderRadius.circular(pt.rXl),
border: Border.all(color: pt.border),
boxShadow: [BoxShadow(color: Colors.black54, blurRadius: 40)],
),
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 8),
child: Row(
children: [
Text('Share your screen',
style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: Icon(Icons.close_rounded, color: pt.fgDim),
onPressed: () => Navigator.pop(context),
),
],
),
),
// Tabs
TabBar(
controller: _tabController,
indicatorColor: pt.accent,
labelColor: pt.accent,
unselectedLabelColor: pt.fgMuted,
dividerColor: pt.border,
tabs: const [
Tab(text: 'Screens'),
Tab(text: 'Windows'),
],
),
// Content
Expanded(
child: _loading
? Center(child: CircularProgressIndicator(color: pt.accent))
: TabBarView(
controller: _tabController,
children: [
_SourceGrid(sources: screens, pt: pt),
_SourceGrid(sources: windows, pt: pt),
],
),
),
// Footer
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.vertical(bottom: Radius.circular(pt.rXl)),
border: Border(top: BorderSide(color: pt.border)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel', style: TextStyle(color: pt.fgMuted)),
),
],
),
),
],
),
),
);
}
}
class _SourceGrid extends StatelessWidget {
final List<DesktopCapturerSource> sources;
final PyramidTheme pt;
const _SourceGrid({required this.sources, required this.pt});
@override
Widget build(BuildContext context) {
if (sources.isEmpty) {
return Center(child: Text('No sources found', style: TextStyle(color: pt.fgDim)));
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 16,
childAspectRatio: 1.4,
),
itemCount: sources.length,
itemBuilder: (context, i) {
final s = sources[i];
return _SourceTile(source: s, pt: pt);
},
);
}
}
class _SourceTile extends StatefulWidget {
final DesktopCapturerSource source;
final PyramidTheme pt;
const _SourceTile({required this.source, required this.pt});
@override
State<_SourceTile> createState() => _SourceTileState();
}
class _SourceTileState extends State<_SourceTile> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => Navigator.pop(context, widget.source),
child: Column(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(
color: _hovered ? pt.accent : pt.border,
width: _hovered ? 2 : 1,
),
),
clipBehavior: Clip.antiAlias,
child: widget.source.thumbnail != null
? Image.memory(
widget.source.thumbnail!,
fit: BoxFit.cover,
width: double.infinity,
)
: Center(child: Icon(Icons.monitor_rounded, color: pt.fgDim, size: 32)),
),
),
const SizedBox(height: 6),
Text(
widget.source.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _hovered ? pt.fg : pt.fgMuted,
fontSize: 12,
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
),
),
],
),
),
);
}
}
+840
View File
@@ -0,0 +1,840 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.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';
// ─── State ────────────────────────────────────────────────────────────────────
enum _Filter { all, media, people }
class _SearchState {
final bool loading;
final List<Event> messageResults;
final List<Room> roomResults;
final String? error;
const _SearchState({
this.loading = false,
this.messageResults = const [],
this.roomResults = const [],
this.error,
});
_SearchState copyWith({
bool? loading,
List<Event>? messageResults,
List<Room>? roomResults,
String? error,
}) => _SearchState(
loading: loading ?? this.loading,
messageResults: messageResults ?? this.messageResults,
roomResults: roomResults ?? this.roomResults,
error: error,
);
}
// ─── Widget ───────────────────────────────────────────────────────────────────
class SearchModal extends ConsumerStatefulWidget {
final VoidCallback onClose;
const SearchModal({super.key, required this.onClose});
@override
ConsumerState<SearchModal> createState() => _SearchModalState();
}
class _SearchModalState extends ConsumerState<SearchModal> {
final _ctrl = TextEditingController();
final _focusNode = FocusNode();
_Filter _filter = _Filter.all;
String _query = '';
int _selected = 0;
_SearchState _state = const _SearchState();
Timer? _debounce;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus());
}
@override
void dispose() {
_debounce?.cancel();
_ctrl.dispose();
_focusNode.dispose();
super.dispose();
}
void _onQueryChanged(String q) {
setState(() {
_query = q;
_selected = 0;
});
_debounce?.cancel();
if (q.trim().isEmpty) {
setState(() => _state = const _SearchState());
return;
}
_debounce = Timer(const Duration(milliseconds: 400), () => _runSearch(q.trim()));
}
Future<void> _runSearch(String q) async {
final client = ref.read(matrixClientProvider).valueOrNull;
if (client == null) return;
setState(() => _state = _state.copyWith(loading: true, error: null));
// Room/people filter: purely local
final allRooms = client.rooms.where((r) => !r.isSpace).toList();
final roomResults = _filter == _Filter.media
? <Room>[]
: allRooms
.where((r) {
if (_filter == _Filter.people && !r.isDirectChat) return false;
if (_filter == _Filter.all && r.isDirectChat) return false;
return r.getLocalizedDisplayname().toLowerCase().contains(q.toLowerCase());
})
.take(5)
.toList();
// Message/media search: LOCAL over decrypted events. The server-side
// /search API can't see E2EE message content (only ciphertext), so it
// returns nothing for encrypted rooms — we search the local database
// (which holds decrypted events) instead, like Element does.
List<Event> messageResults = [];
if (_filter != _Filter.people) {
try {
messageResults = await _localMessageSearch(client, q);
} catch (e) {
setState(() => _state = _state.copyWith(
loading: false,
error: 'Suche fehlgeschlagen: $e',
));
return;
}
}
if (mounted) {
setState(() => _state = _SearchState(
loading: false,
messageResults: messageResults,
roomResults: roomResults,
));
}
}
// Scans the local (decrypted) event database across all rooms for messages
// whose body matches the query. For the media filter, only media messages
// whose filename/caption matches are returned.
Future<List<Event>> _localMessageSearch(Client client, String q) async {
final db = client.database;
final lower = q.toLowerCase();
final media = _filter == _Filter.media;
const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'};
const perRoomScanCap = 1500;
const totalCap = 60;
const chunk = 500;
final results = <Event>[];
for (final room in client.rooms.where((r) => !r.isSpace)) {
var start = 0;
var scanned = 0;
try {
while (scanned < perRoomScanCap) {
final events = await db.getEventList(room, start: start, limit: chunk);
if (events.isEmpty) break;
for (final e in events) {
if (e.type != EventTypes.Message || e.redacted) continue;
final msgtype = e.content.tryGet<String>('msgtype') ?? '';
if (media && !mediaTypes.contains(msgtype)) continue;
if (!e.body.toLowerCase().contains(lower)) continue;
results.add(e);
}
start += chunk;
scanned += events.length;
if (events.length < chunk || results.length >= totalCap) break;
}
} catch (_) {}
if (results.length >= totalCap) break;
}
results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs));
return results.take(totalCap).toList();
}
void _openRoom(String roomId, {String? eventId}) {
widget.onClose();
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
if (eventId != null) {
ref.read(pendingJumpEventProvider.notifier).state = eventId;
}
}
int get _totalResults =>
_state.roomResults.length + _state.messageResults.length;
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final client = ref.watch(matrixClientProvider).valueOrNull;
final filters = [
(_Filter.all, 'Alle', Icons.auto_awesome_rounded),
(_Filter.media, 'Medien', Icons.image_rounded),
(_Filter.people, 'Personen & Räume', Icons.group_rounded),
];
return KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (e) {
if (e is! KeyDownEvent) return;
if (e.logicalKey == LogicalKeyboardKey.escape) widget.onClose();
if (e.logicalKey == LogicalKeyboardKey.arrowDown) {
setState(() => _selected = (_selected + 1) % _totalResults.clamp(1, 999));
}
if (e.logicalKey == LogicalKeyboardKey.arrowUp) {
setState(() => _selected = (_selected - 1).clamp(0, _totalResults - 1));
}
if (e.logicalKey == LogicalKeyboardKey.enter) {
_activateSelected(client);
}
},
child: GestureDetector(
onTap: widget.onClose,
child: Container(
color: Colors.black.withAlpha(150),
alignment: Alignment.topCenter,
padding: const EdgeInsets.only(top: 80),
child: GestureDetector(
onTap: () {},
child: Container(
width: 640,
constraints: const BoxConstraints(maxHeight: 560),
decoration: BoxDecoration(
color: pt.bg1,
borderRadius: BorderRadius.circular(pt.rXl),
border: Border.all(color: pt.border),
boxShadow: [
BoxShadow(color: Colors.black.withAlpha(180), blurRadius: 60, offset: const Offset(0, 20)),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Input
_SearchBar(ctrl: _ctrl, focusNode: _focusNode, onChanged: _onQueryChanged, pt: pt),
// Filters
_FilterRow(
filters: filters,
active: _filter,
pt: pt,
onSelect: (f) {
setState(() { _filter = f; _selected = 0; });
if (_query.trim().isNotEmpty) _runSearch(_query.trim());
},
),
// Results
Flexible(child: _ResultsPane(
state: _state,
query: _query,
filter: _filter,
selected: _selected,
pt: pt,
client: client,
onOpenRoom: _openRoom,
)),
// Footer
_Footer(
count: _totalResults,
loading: _state.loading,
pt: pt,
),
],
),
),
),
),
),
);
}
void _activateSelected(Client? client) {
final roomCount = _state.roomResults.length;
if (_selected < roomCount) {
_openRoom(_state.roomResults[_selected].id);
} else {
final idx = _selected - roomCount;
if (idx < _state.messageResults.length) {
final e = _state.messageResults[idx];
final rid = e.roomId;
if (rid != null) _openRoom(rid, eventId: e.eventId);
}
}
}
}
// ─── Search bar ───────────────────────────────────────────────────────────────
class _SearchBar extends StatelessWidget {
final TextEditingController ctrl;
final FocusNode focusNode;
final ValueChanged<String> onChanged;
final PyramidTheme pt;
const _SearchBar({required this.ctrl, required this.focusNode, required this.onChanged, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
child: Row(
children: [
Icon(Icons.search_rounded, size: 20, color: pt.fgDim),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: ctrl,
focusNode: focusNode,
style: TextStyle(color: pt.fg, fontSize: 15),
cursorColor: pt.accent,
decoration: InputDecoration(
hintText: 'Nachrichten, Medien, Personen, Räume suchen…',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 15),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
),
onChanged: onChanged,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: pt.border),
),
child: Text('esc', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
),
],
),
);
}
}
// ─── Filter row ───────────────────────────────────────────────────────────────
class _FilterRow extends StatelessWidget {
final List<(_Filter, String, IconData)> filters;
final _Filter active;
final PyramidTheme pt;
final ValueChanged<_Filter> onSelect;
const _FilterRow({required this.filters, required this.active, required this.pt, required this.onSelect});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: filters.map((f) => Padding(
padding: const EdgeInsets.only(right: 6),
child: _Chip(label: f.$2, icon: f.$3, active: active == f.$1, pt: pt, onTap: () => onSelect(f.$1)),
)).toList(),
),
),
);
}
}
// ─── Results pane ─────────────────────────────────────────────────────────────
class _ResultsPane extends StatelessWidget {
final _SearchState state;
final String query;
final _Filter filter;
final int selected;
final PyramidTheme pt;
final Client? client;
final void Function(String roomId, {String? eventId}) onOpenRoom;
const _ResultsPane({
required this.state,
required this.query,
required this.filter,
required this.selected,
required this.pt,
required this.client,
required this.onOpenRoom,
});
@override
Widget build(BuildContext context) {
if (query.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.search_rounded, size: 36, color: pt.fgDim),
const SizedBox(height: 10),
Text('Tippe um zu suchen', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
]),
),
);
}
if (state.loading) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
),
);
}
if (state.error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(state.error!, style: TextStyle(color: pt.danger, fontSize: 13)),
),
);
}
final hasRooms = state.roomResults.isNotEmpty;
final hasMessages = state.messageResults.isNotEmpty;
if (!hasRooms && !hasMessages) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text('Keine Ergebnisse für "$query"', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
),
);
}
int itemIndex = 0;
final items = <Widget>[];
if (hasRooms) {
items.add(_SectionHeader(label: filter == _Filter.people ? 'Personen & Räume' : 'Räume', pt: pt));
for (final room in state.roomResults) {
final idx = itemIndex++;
items.add(_RoomItem(room: room, selected: selected == idx, pt: pt, onTap: () => onOpenRoom(room.id)));
}
}
if (hasMessages) {
items.add(_SectionHeader(label: filter == _Filter.media ? 'Medien' : 'Nachrichten', pt: pt));
for (final event in state.messageResults) {
final idx = itemIndex++;
final room = client?.getRoomById(event.roomId ?? '') ?? event.room;
items.add(_MessageItem(
event: event,
room: room,
selected: selected == idx,
pt: pt,
onTap: () => onOpenRoom(event.roomId ?? '', eventId: event.eventId),
));
}
}
return ListView(
padding: const EdgeInsets.symmetric(vertical: 4),
shrinkWrap: true,
children: items,
);
}
}
// ─── Section header ───────────────────────────────────────────────────────────
class _SectionHeader extends StatelessWidget {
final String label;
final PyramidTheme pt;
const _SectionHeader({required this.label, required this.pt});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
child: Text(label.toUpperCase(), style: TextStyle(color: pt.fgDim, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.8)),
);
}
// ─── Room result item ─────────────────────────────────────────────────────────
class _RoomItem extends StatefulWidget {
final Room room;
final bool selected;
final PyramidTheme pt;
final VoidCallback onTap;
const _RoomItem({required this.room, required this.selected, required this.pt, required this.onTap});
@override
State<_RoomItem> createState() => _RoomItemState();
}
class _RoomItemState extends State<_RoomItem> {
bool _hovered = false;
Color _colorForName(String n) {
const c = [Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFFFF6B9D), Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFF3B82F6)];
return c[n.codeUnits.fold(0, (a, b) => a + b) % c.length];
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final room = widget.room;
final name = room.getLocalizedDisplayname();
final avatarUrl = room.avatar;
final isDm = room.isDirectChat;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
margin: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: widget.selected || _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
children: [
SizedBox(width: 32, height: 32,
child: avatarUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(isDm ? 16 : pt.rSm),
child: MxcImage(mxcUri: avatarUrl.toString(), client: room.client, width: 32, height: 32),
)
: Container(
decoration: BoxDecoration(
color: _colorForName(name),
borderRadius: BorderRadius.circular(isDm ? 16 : pt.rSm),
),
child: Center(child: Text(name.isEmpty ? '?' : name[0].toUpperCase(),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14))),
),
),
const SizedBox(width: 10),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(isDm ? Icons.person_rounded : Icons.tag_rounded, size: 11, color: pt.fgDim),
const SizedBox(width: 3),
Expanded(child: Text(name, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis)),
]),
if (room.lastEvent != null)
Text(room.lastEvent!.body, style: TextStyle(color: pt.fgMuted, fontSize: 12), overflow: TextOverflow.ellipsis, maxLines: 1),
],
)),
if (room.notificationCount > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: pt.accent, borderRadius: BorderRadius.circular(999)),
child: Text('${room.notificationCount}', style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600)),
),
],
),
),
),
);
}
}
// ─── Message result item ──────────────────────────────────────────────────────
class _MessageItem extends StatefulWidget {
final Event event;
final Room? room;
final bool selected;
final PyramidTheme pt;
final VoidCallback onTap;
const _MessageItem({required this.event, required this.room, required this.selected, required this.pt, required this.onTap});
@override
State<_MessageItem> createState() => _MessageItemState();
}
class _MessageItemState extends State<_MessageItem> {
bool _hovered = false;
String _senderName() {
final event = widget.event;
final room = widget.room;
if (room != null) {
final member = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
return member.displayName ?? event.senderId.split(':').first.replaceFirst('@', '');
}
return event.senderId.split(':').first.replaceFirst('@', '');
}
String _timeLabel() {
final ts = widget.event.originServerTs;
final now = DateTime.now();
final diff = now.difference(ts);
if (diff.inDays == 0) return '${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}';
if (diff.inDays < 7) return _weekday(ts.weekday);
return '${ts.day}.${ts.month}.${ts.year}';
}
String _weekday(int d) => ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'][d - 1];
String _msgBody() {
final content = widget.event.content;
final msgtype = content.tryGet<String>('msgtype') ?? '';
if (msgtype == 'm.image') return '📷 Bild';
if (msgtype == 'm.video') return '🎬 Video';
if (msgtype == 'm.audio') return '🎵 Audio';
if (msgtype == 'm.file') return '📎 ${content.tryGet<String>('body') ?? 'Datei'}';
return content.tryGet<String>('body') ?? '';
}
IconData _roomIcon() {
final room = widget.room;
if (room == null) return Icons.tag_rounded;
return room.isDirectChat ? Icons.person_rounded : Icons.tag_rounded;
}
bool get _isVisualMedia {
final t = widget.event.content.tryGet<String>('msgtype') ?? '';
return t == 'm.image' || t == 'm.video';
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final roomName = widget.room?.getLocalizedDisplayname() ?? widget.event.roomId ?? '';
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: widget.selected || _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_isVisualMedia)
Padding(
padding: const EdgeInsets.only(top: 1, right: 8),
child: _SearchThumb(event: widget.event, pt: pt),
)
else ...[
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(_roomIcon(), size: 14, color: pt.fgDim),
),
const SizedBox(width: 8),
],
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(child: Text(roomName, style: TextStyle(color: pt.fgMuted, fontSize: 11), overflow: TextOverflow.ellipsis)),
Text(_timeLabel(), style: TextStyle(color: pt.fgDim, fontSize: 11)),
]),
const SizedBox(height: 1),
RichText(
maxLines: 2,
overflow: TextOverflow.ellipsis,
text: TextSpan(children: [
TextSpan(text: '${_senderName()}: ', style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600)),
TextSpan(text: _msgBody(), style: TextStyle(color: pt.fgMuted, fontSize: 13)),
]),
),
],
)),
],
),
),
),
);
}
}
// ─── Search media thumbnail ───────────────────────────────────────────────────
// Loads (and decrypts, if needed) a small thumbnail for image/video results.
class _SearchThumb extends StatefulWidget {
final Event event;
final PyramidTheme pt;
const _SearchThumb({required this.event, required this.pt});
@override
State<_SearchThumb> createState() => _SearchThumbState();
}
class _SearchThumbState extends State<_SearchThumb> {
static final Map<String, Uint8List?> _cache = {};
Uint8List? _bytes;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final id = widget.event.eventId;
if (_cache.containsKey(id)) {
setState(() => _bytes = _cache[id]);
return;
}
try {
final file = await widget.event
.downloadAndDecryptAttachment(getThumbnail: true)
.timeout(const Duration(seconds: 10));
_cache[id] = file.bytes;
if (mounted) setState(() => _bytes = file.bytes);
} catch (_) {
_cache[id] = null;
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
const size = 38.0;
return ClipRRect(
borderRadius: BorderRadius.circular(pt.rSm),
child: SizedBox(
width: size,
height: size,
child: _bytes != null
? Image.memory(_bytes!, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _placeholder(pt))
: _placeholder(pt),
),
);
}
Widget _placeholder(PyramidTheme pt) => Container(
color: pt.bg3,
child: Icon(
(widget.event.content.tryGet<String>('msgtype') ?? '') == 'm.video'
? Icons.videocam_rounded
: Icons.image_rounded,
size: 18,
color: pt.fgDim,
),
);
}
// ─── Shared chips / footer ────────────────────────────────────────────────────
class _Chip extends StatefulWidget {
final String label;
final IconData icon;
final bool active;
final PyramidTheme pt;
final VoidCallback onTap;
const _Chip({required this.label, required this.icon, required this.active, required this.pt, required this.onTap});
@override
State<_Chip> createState() => _ChipState();
}
class _ChipState extends State<_Chip> {
bool _h = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _h = true),
onExit: (_) => setState(() => _h = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: widget.active ? pt.accentSoft : _h ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: widget.active ? pt.accent : pt.border),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(widget.icon, size: 11, color: widget.active ? pt.accent : pt.fgMuted),
const SizedBox(width: 4),
Text(widget.label, style: TextStyle(
color: widget.active ? pt.accent : pt.fgMuted,
fontSize: 12,
fontWeight: widget.active ? FontWeight.w600 : FontWeight.w400,
)),
]),
),
),
);
}
}
class _Footer extends StatelessWidget {
final int count;
final bool loading;
final PyramidTheme pt;
const _Footer({required this.count, required this.loading, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: pt.bg2,
border: Border(top: BorderSide(color: pt.border)),
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(pt.rXl), bottomRight: Radius.circular(pt.rXl)),
),
child: Row(
children: [
_Hint('↑↓', 'navigieren', pt),
const SizedBox(width: 16),
_Hint('', 'öffnen', pt),
const Spacer(),
if (loading)
SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5, color: pt.fgDim))
else
Text(
'$count Ergebnis${count == 1 ? '' : 'se'}',
style: TextStyle(color: pt.fgDim, fontSize: 11),
),
],
),
);
}
}
class _Hint extends StatelessWidget {
final String key_;
final String label;
final PyramidTheme pt;
const _Hint(this.key_, this.label, this.pt);
@override
Widget build(BuildContext context) => Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(color: pt.bg3, borderRadius: BorderRadius.circular(4), border: Border.all(color: pt.border)),
child: Text(key_, style: TextStyle(color: pt.fgMuted, fontSize: 11, fontFamily: 'monospace')),
),
const SizedBox(width: 4),
Text(label, style: TextStyle(color: pt.fgDim, fontSize: 11)),
]);
}
File diff suppressed because it is too large Load Diff
+297
View File
@@ -0,0 +1,297 @@
import 'dart:io';
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/features/chat/attachment_dialog.dart';
import 'package:pyramid/widgets/mxc_image.dart';
/// "Teilen nach Pyramid": Raum-Picker für geteilte Inhalte (Text/Dateien).
/// Nach Auswahl wird der Raum geöffnet; Dateien laufen durch den
/// AttachmentDialog (mit Vorschau), Text wird direkt gesendet.
class ShareTargetDialog {
static Future<void> show(
BuildContext context,
WidgetRef ref,
PendingShare share,
) async {
final client = await ref.read(matrixClientProvider.future);
if (!context.mounted) return;
final room = await showGeneralDialog<Room>(
context: context,
barrierDismissible: true,
barrierLabel: 'Teilen',
barrierColor: Colors.black54,
transitionDuration: const Duration(milliseconds: 180),
pageBuilder: (_, __, ___) => _ShareTargetPicker(client: client, share: share),
transitionBuilder: (_, anim, __, child) {
final curved = CurvedAnimation(parent: anim, curve: Curves.easeOutBack);
return FadeTransition(
opacity: anim,
child: ScaleTransition(
scale: Tween(begin: 0.94, end: 1.0).animate(curved),
child: child,
),
);
},
);
if (room == null || !context.mounted) return;
// Zielraum öffnen.
ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
var filesSent = false;
if (share.paths.isNotEmpty) {
final files = share.paths
.map(File.new)
.where((f) => f.existsSync())
.toList();
if (files.isNotEmpty) {
filesSent = await AttachmentDialog.show(context, files, room);
for (final f in files) {
f.delete().catchError((_) => f);
}
}
}
final text = share.text?.trim() ?? '';
if (text.isNotEmpty && (share.paths.isEmpty || filesSent)) {
try {
await room.sendTextEvent(text);
} catch (_) {}
}
}
}
class _ShareTargetPicker extends StatefulWidget {
final Client client;
final PendingShare share;
const _ShareTargetPicker({required this.client, required this.share});
@override
State<_ShareTargetPicker> createState() => _ShareTargetPickerState();
}
class _ShareTargetPickerState extends State<_ShareTargetPicker> {
String _filter = '';
List<Room> get _rooms {
final rooms = widget.client.rooms
.where((r) =>
r.membership == Membership.join &&
!r.isSpace &&
(_filter.isEmpty ||
r
.getLocalizedDisplayname()
.toLowerCase()
.contains(_filter.toLowerCase())))
.toList()
// Zuletzt aktive Chats zuerst — wie bei Discord/WhatsApp-Share.
..sort((a, b) => (b.lastEvent?.originServerTs ??
DateTime.fromMillisecondsSinceEpoch(0))
.compareTo(a.lastEvent?.originServerTs ??
DateTime.fromMillisecondsSinceEpoch(0)));
return rooms;
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final size = MediaQuery.sizeOf(context);
final rooms = _rooms;
final share = widget.share;
final fileCount = share.paths.length;
return Center(
child: Material(
color: Colors.transparent,
child: Container(
width: (size.width - 32).clamp(280.0, 440.0),
height: (size.height - 80).clamp(320.0, 560.0),
decoration: BoxDecoration(
color: pt.bg1,
borderRadius: BorderRadius.circular(pt.rXl),
border: Border.all(color: pt.border),
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 40)],
),
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 12, 4),
child: Row(
children: [
Icon(Icons.share_rounded, size: 18, color: pt.accent),
const SizedBox(width: 10),
Expanded(
child: Text(
'Teilen nach…',
style: TextStyle(
color: pt.fg,
fontSize: 16,
fontWeight: FontWeight.w700),
),
),
IconButton(
icon: Icon(Icons.close_rounded, size: 18, color: pt.fgDim),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Vorschau des geteilten Inhalts
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 10),
child: Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: Row(
children: [
Icon(
fileCount > 0
? Icons.attach_file_rounded
: Icons.notes_rounded,
size: 14,
color: pt.fgMuted,
),
const SizedBox(width: 8),
Expanded(
child: Text(
fileCount > 0
? '$fileCount Datei${fileCount == 1 ? '' : 'en'}'
'${(share.text?.isNotEmpty ?? false) ? ' + Text' : ''}'
: (share.text ?? ''),
style: TextStyle(color: pt.fgMuted, fontSize: 12),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
// Suche
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: TextField(
autofocus: false,
onChanged: (v) => setState(() => _filter = v),
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
hintText: 'Chat suchen…',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
prefixIcon:
Icon(Icons.search_rounded, size: 16, color: pt.fgDim),
isDense: true,
filled: true,
fillColor: pt.bg2,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rBase),
borderSide: BorderSide(color: pt.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rBase),
borderSide: BorderSide(color: pt.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rBase),
borderSide: BorderSide(color: pt.accent),
),
),
),
),
// Raumliste
Expanded(
child: rooms.isEmpty
? Center(
child: Text('Keine Chats gefunden',
style: TextStyle(color: pt.fgDim, fontSize: 13)),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
itemCount: rooms.length,
itemBuilder: (_, i) =>
_RoomRow(room: rooms[i], pt: pt, client: widget.client),
),
),
],
),
),
),
);
}
}
class _RoomRow extends StatelessWidget {
final Room room;
final PyramidTheme pt;
final Client client;
const _RoomRow({required this.room, required this.pt, required this.client});
@override
Widget build(BuildContext context) {
final name = room.getLocalizedDisplayname();
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
return InkWell(
borderRadius: BorderRadius.circular(pt.rBase),
onTap: () => Navigator.of(context).pop(room),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
if (room.avatar != null)
MxcAvatar(
mxcUri: room.avatar,
client: client,
size: 30,
borderRadius: BorderRadius.circular(15),
placeholder: (_) => _initialCircle(initial),
)
else
_initialCircle(initial),
const SizedBox(width: 12),
Expanded(
child: Text(
name,
style: TextStyle(
color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
if (room.isDirectChat)
Icon(Icons.person_outline_rounded, size: 14, color: pt.fgDim)
else
Icon(Icons.tag_rounded, size: 14, color: pt.fgDim),
],
),
),
);
}
Widget _initialCircle(String initial) => Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: pt.accent.withAlpha(60),
shape: BoxShape.circle,
),
child: Center(
child: Text(
initial,
style: TextStyle(
color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600),
),
),
);
}
+33 -49
View File
@@ -8,7 +8,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/update_checker.dart';
import 'package:pyramid/widgets/pyramid_logo.dart';
import 'package:pyramid/widgets/pyramid_loader.dart';
// ─── Entry point ──────────────────────────────────────────────────────────────
@@ -36,10 +36,7 @@ class _UpdateDownloadDialog extends ConsumerStatefulWidget {
_UpdateDownloadDialogState();
}
class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog>
with SingleTickerProviderStateMixin {
late final AnimationController _spinCtrl;
class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog> {
_DownloadState _state = _DownloadState.idle;
double _progress = 0.0;
int _received = 0;
@@ -49,19 +46,9 @@ class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog>
@override
void initState() {
super.initState();
_spinCtrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
)..repeat();
_startDownload();
}
@override
void dispose() {
_spinCtrl.dispose();
super.dispose();
}
// ─── Download ───────────────────────────────────────────────────────────────
Future<void> _startDownload() async {
@@ -127,6 +114,22 @@ class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog>
.invokeMethod('installApk', {'path': file.path});
if (mounted) setState(() => _state = _DownloadState.done);
}
} on PlatformException catch (e) {
if (e.code == 'PERMISSION_REQUIRED') {
if (mounted) {
setState(() {
_state = _DownloadState.error;
_errorMsg = 'Bitte "Unbekannte Apps installieren" für Pyramid erlauben (Einstellungen geöffnet) — dann erneut versuchen.';
});
}
} else {
if (mounted) {
setState(() {
_state = _DownloadState.error;
_errorMsg = e.message ?? e.toString();
});
}
}
} catch (e) {
if (mounted) {
setState(() {
@@ -170,21 +173,24 @@ class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Animated Pyramid ──
_AnimatedPyramid(ctrl: _spinCtrl, pt: pt),
// ── Loading animation ──
const PyramidLoader(size: 72),
const SizedBox(height: 24),
// ── Title ──
Text(
_titleText(),
style: TextStyle(
color: pt.fg,
fontSize: 16,
fontWeight: FontWeight.w700,
// ── Title (hidden while actively downloading) ──
if (_state != _DownloadState.downloading &&
_state != _DownloadState.installing) ...[
Text(
_titleText(),
style: TextStyle(
color: pt.fg,
fontSize: 16,
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
const SizedBox(height: 6),
],
Text(
widget.info.tagName,
style: TextStyle(color: pt.accent, fontSize: 13),
@@ -285,28 +291,6 @@ class _UpdateDownloadDialogState extends ConsumerState<_UpdateDownloadDialog>
}
}
// ─── Animated pyramid ─────────────────────────────────────────────────────────
class _AnimatedPyramid extends StatelessWidget {
final AnimationController ctrl;
final PyramidTheme pt;
const _AnimatedPyramid({required this.ctrl, required this.pt});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: ctrl,
builder: (context, _) {
final pulse = 0.9 + 0.1 * (0.5 - (ctrl.value - 0.5).abs()) * 2;
return Transform.scale(
scale: pulse,
child: PyramidLogo(size: 72, color: pt.accent),
);
},
);
}
}
// ─── Progress bar ─────────────────────────────────────────────────────────────
class _ProgressBar extends StatelessWidget {