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