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,255 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/chat/media_player.dart';
|
||||
import 'package:pyramid/utils/gif_favorite_service.dart';
|
||||
|
||||
/// Overlay viewer for images, GIFs and videos.
|
||||
/// Uses a semi-transparent backdrop; tapping outside the content closes it.
|
||||
class MediaViewer extends StatefulWidget {
|
||||
final String senderName;
|
||||
final Uint8List? bytes;
|
||||
final String? networkUrl;
|
||||
final String filename;
|
||||
final bool isVideo;
|
||||
final Event? event;
|
||||
|
||||
const MediaViewer({
|
||||
super.key,
|
||||
required this.senderName,
|
||||
required this.filename,
|
||||
this.bytes,
|
||||
this.networkUrl,
|
||||
this.isVideo = false,
|
||||
this.event,
|
||||
});
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required String senderName,
|
||||
required String filename,
|
||||
Uint8List? bytes,
|
||||
String? networkUrl,
|
||||
bool isVideo = false,
|
||||
Event? event,
|
||||
}) {
|
||||
return showGeneralDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Schließen',
|
||||
barrierColor: Colors.black.withAlpha(178),
|
||||
transitionDuration: const Duration(milliseconds: 180),
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
|
||||
senderName: senderName,
|
||||
filename: filename,
|
||||
bytes: bytes,
|
||||
networkUrl: networkUrl,
|
||||
isVideo: isVideo,
|
||||
event: event,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<MediaViewer> createState() => _MediaViewerState();
|
||||
}
|
||||
|
||||
class _MediaViewerState extends State<MediaViewer> {
|
||||
final _tc = TransformationController();
|
||||
bool _controlsVisible = true;
|
||||
bool _saving = false;
|
||||
bool _savingSticker = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
|
||||
|
||||
void _resetZoom() => _tc.value = Matrix4.identity();
|
||||
|
||||
Future<void> _saveAsSticker() async {
|
||||
final event = widget.event;
|
||||
if (event == null || _savingSticker) return;
|
||||
setState(() => _savingSticker = true);
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
try {
|
||||
final ok = await GifFavoriteService.addImageAsStickerFavorite(event);
|
||||
messenger?.showSnackBar(SnackBar(
|
||||
content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'),
|
||||
duration: const Duration(seconds: 2),
|
||||
));
|
||||
} finally {
|
||||
if (mounted) setState(() => _savingSticker = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final b = widget.bytes;
|
||||
if (b == null || _saving) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
Directory? dir;
|
||||
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
||||
dir = await getDownloadsDirectory();
|
||||
}
|
||||
dir ??= await getTemporaryDirectory();
|
||||
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'jpg';
|
||||
final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext';
|
||||
final file = File('${dir.path}/$name');
|
||||
await file.writeAsBytes(b);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Gespeichert: ${file.path}'),
|
||||
duration: const Duration(seconds: 4),
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Fehler: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final isVideo = widget.isVideo && widget.bytes != null;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
final header = AnimatedOpacity(
|
||||
opacity: _controlsVisible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_controlsVisible,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black54, Colors.transparent],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded, color: Colors.white),
|
||||
tooltip: 'Schließen',
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(widget.senderName,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
Text(widget.filename,
|
||||
style: const TextStyle(color: Colors.white60, fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.event != null && !isVideo)
|
||||
IconButton(
|
||||
onPressed: _savingSticker ? null : _saveAsSticker,
|
||||
tooltip: 'Als Sticker speichern',
|
||||
icon: _savingSticker
|
||||
? const SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.sticky_note_2_outlined, color: Colors.white),
|
||||
),
|
||||
if (widget.bytes != null)
|
||||
IconButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
tooltip: 'Speichern',
|
||||
icon: _saving
|
||||
? const SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.download_rounded, color: Colors.white),
|
||||
),
|
||||
if (!isVideo)
|
||||
IconButton(
|
||||
onPressed: _resetZoom,
|
||||
tooltip: 'Zoom zurücksetzen',
|
||||
icon: const Icon(Icons.zoom_out_map_rounded, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Content area — constrained so the transparent Material outside it lets
|
||||
// pointer events through to the barrier (which dismisses on tap).
|
||||
final contentMaxW = (size.width - 48).clamp(200.0, 1200.0);
|
||||
final contentMaxH = (size.height - 48).clamp(200.0, 900.0);
|
||||
|
||||
if (isVideo) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: contentMaxW,
|
||||
height: contentMaxH,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
|
||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget img;
|
||||
if (widget.bytes != null) {
|
||||
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
|
||||
} else if (widget.networkUrl != null) {
|
||||
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
|
||||
} else {
|
||||
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
|
||||
}
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: contentMaxW,
|
||||
height: contentMaxH,
|
||||
child: GestureDetector(
|
||||
onTap: _toggle,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
InteractiveViewer(
|
||||
transformationController: _tc,
|
||||
minScale: 0.5,
|
||||
maxScale: 10.0,
|
||||
child: Center(child: img),
|
||||
),
|
||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user