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