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:
@@ -1,148 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
|
||||
class RoomListItem extends StatelessWidget {
|
||||
final Room room;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const RoomListItem({super.key, required this.room, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unread = room.notificationCount;
|
||||
final lastEvent = room.lastEvent;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
leading: _RoomAvatar(room: room),
|
||||
title: Text(
|
||||
room.getLocalizedDisplayname(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: unread > 0
|
||||
? theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold)
|
||||
: theme.textTheme.bodyLarge,
|
||||
),
|
||||
subtitle: lastEvent != null
|
||||
? Text(
|
||||
_previewText(lastEvent),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall,
|
||||
)
|
||||
: null,
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (lastEvent != null)
|
||||
Text(
|
||||
_formatTime(lastEvent.originServerTs),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: unread > 0
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (unread > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
_UnreadBadge(count: unread),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _previewText(Event event) {
|
||||
if (event.type == EventTypes.Message) {
|
||||
final body = event.body;
|
||||
if (body.isNotEmpty) return body;
|
||||
}
|
||||
return event.type;
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
final now = DateTime.now();
|
||||
if (time.day == now.day &&
|
||||
time.month == now.month &&
|
||||
time.year == now.year) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
return '${time.day.toString().padLeft(2, '0')}.${time.month.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomAvatar extends StatelessWidget {
|
||||
final Room room;
|
||||
const _RoomAvatar({required this.room});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final avatar = room.avatar;
|
||||
final name = room.getLocalizedDisplayname();
|
||||
final initials = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
||||
final color = _colorForName(name, Theme.of(context).colorScheme);
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: color,
|
||||
child: ClipOval(
|
||||
child: avatar != null
|
||||
? MxcImage(
|
||||
mxcUri: avatar,
|
||||
width: 48,
|
||||
height: 48,
|
||||
placeholder: (_) => Text(
|
||||
initials,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
initials,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _colorForName(String name, ColorScheme scheme) {
|
||||
final colors = [
|
||||
scheme.primary,
|
||||
scheme.secondary,
|
||||
scheme.tertiary,
|
||||
Colors.teal,
|
||||
Colors.indigo,
|
||||
Colors.orange,
|
||||
];
|
||||
final hash = name.codeUnits.fold(0, (a, b) => a + b);
|
||||
return colors[hash % colors.length];
|
||||
}
|
||||
}
|
||||
|
||||
class _UnreadBadge extends StatelessWidget {
|
||||
final int count;
|
||||
const _UnreadBadge({required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
count > 99 ? '99+' : '$count',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:pyramid/features/rooms/room_list_item.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||
|
||||
class RoomsPage extends ConsumerWidget {
|
||||
const RoomsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final roomsAsync = ref.watch(roomListProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Pyramid'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () => context.go('/settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: roomsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Fehler: $e')),
|
||||
data: (rooms) {
|
||||
if (rooms.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.forum_outlined, size: 64),
|
||||
SizedBox(height: 16),
|
||||
Text('Noch keine Räume'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: rooms.length,
|
||||
separatorBuilder: (context, i) => const Divider(height: 1, indent: 72),
|
||||
itemBuilder: (context, i) {
|
||||
final room = rooms[i];
|
||||
return RoomListItem(
|
||||
room: room,
|
||||
onTap: () => context.go('/chat/${Uri.encodeComponent(room.id)}'),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,18 +65,28 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
activeSpaceId != 'dms' &&
|
||||
activeSpaceId != 'rooms';
|
||||
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
final forceJoined = ref.watch(forceJoinedSpacesProvider);
|
||||
final activeSpaceRoom =
|
||||
isRealSpace ? client?.getRoomById(activeSpaceId) : null;
|
||||
final isInvitedSpace = activeSpaceRoom?.membership == Membership.invite &&
|
||||
!forceJoined.contains(activeSpaceId);
|
||||
|
||||
return Container(
|
||||
color: pt.bg1,
|
||||
child: Column(
|
||||
children: [
|
||||
_Header(pt: pt, spaceId: activeSpaceId),
|
||||
_FilterInput(
|
||||
controller: _filterCtrl,
|
||||
pt: pt,
|
||||
onChanged: (v) => setState(() => _filter = v),
|
||||
),
|
||||
if (!isInvitedSpace)
|
||||
_FilterInput(
|
||||
controller: _filterCtrl,
|
||||
pt: pt,
|
||||
onChanged: (v) => setState(() => _filter = v),
|
||||
),
|
||||
Expanded(
|
||||
child: isRealSpace
|
||||
child: isInvitedSpace
|
||||
? _SpaceInviteView(space: activeSpaceRoom!, pt: pt)
|
||||
: isRealSpace
|
||||
? _SpaceRoomsList(
|
||||
spaceId: activeSpaceId,
|
||||
filter: _filter,
|
||||
@@ -111,21 +121,213 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (call.isActive && !call.isVoiceChannel) MiniCallWidget(call: call),
|
||||
if (voip.currentCall != null &&
|
||||
voip.currentCall!.state != CallState.kEnded)
|
||||
MiniCallWidget(call: voip),
|
||||
const UserPanel(),
|
||||
// Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden.
|
||||
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
|
||||
// Konto-Balken nebeneinander statt übereinander.
|
||||
Builder(builder: (context) {
|
||||
final hasVoipCall = voip.currentCall != null &&
|
||||
voip.currentCall!.state != CallState.kEnded;
|
||||
final hasCallDock = call.isActive || hasVoipCall;
|
||||
final isLandscapeCompact =
|
||||
MediaQuery.sizeOf(context).height < 500;
|
||||
|
||||
final callDock = AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (call.isActive) MiniCallWidget(call: call),
|
||||
if (hasVoipCall) MiniCallWidget(call: voip),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (isLandscapeCompact && hasCallDock) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: callDock),
|
||||
const Expanded(child: UserPanel()),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [callDock, const UserPanel()],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Space invite view ─────────────────────────────────────────────────────────
|
||||
|
||||
class _SpaceInviteView extends ConsumerStatefulWidget {
|
||||
final Room space;
|
||||
final PyramidTheme pt;
|
||||
const _SpaceInviteView({required this.space, required this.pt});
|
||||
|
||||
@override
|
||||
ConsumerState<_SpaceInviteView> createState() => _SpaceInviteViewState();
|
||||
}
|
||||
|
||||
class _SpaceInviteViewState extends ConsumerState<_SpaceInviteView> {
|
||||
bool _accepting = false;
|
||||
bool _declining = false;
|
||||
|
||||
Future<void> _accept() async {
|
||||
setState(() => _accepting = true);
|
||||
final client = widget.space.client;
|
||||
final id = widget.space.id;
|
||||
try {
|
||||
await client.joinRoomById(id).timeout(const Duration(seconds: 20));
|
||||
// Server confirmed the join. Switch to the channel list optimistically —
|
||||
// the local sync membership may lag behind, so don't wait for it.
|
||||
ref.read(forceJoinedSpacesProvider.notifier).add(id);
|
||||
if (mounted) setState(() => _accepting = false);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _accepting = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
'Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'),
|
||||
backgroundColor: widget.pt.danger,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _decline() async {
|
||||
setState(() => _declining = true);
|
||||
try {
|
||||
await widget.space.leave();
|
||||
if (mounted) {
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _declining = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
final name = widget.space.getLocalizedDisplayname();
|
||||
final busy = _accepting || _declining;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accent.withAlpha(40),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: client != null && widget.space.avatar != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: MxcAvatar(
|
||||
mxcUri: widget.space.avatar,
|
||||
client: client,
|
||||
size: 72,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
placeholder: (_) => Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: pt.accent,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: pt.accent,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Einladung zum Space',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
name,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: pt.fg, fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: pt.danger,
|
||||
side: BorderSide(color: pt.danger.withAlpha(100)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
onPressed: busy ? null : _decline,
|
||||
child: _declining
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2))
|
||||
: const Text('Ablehnen'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: pt.accent,
|
||||
foregroundColor: pt.accentFg,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
onPressed: busy ? null : _accept,
|
||||
child: _accepting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2))
|
||||
: const Text('Beitreten'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Header ───────────────────────────────────────────────────────────────────
|
||||
|
||||
enum _SpaceMenuItem {
|
||||
newDm, createRoom, joinRoom, discoverRooms,
|
||||
newDm, createGroup, createRoom, joinRoom, discoverRooms,
|
||||
editSpace, adminSpace, leaveSpace,
|
||||
}
|
||||
|
||||
@@ -159,6 +361,9 @@ class _Header extends ConsumerWidget {
|
||||
switch (item) {
|
||||
case _SpaceMenuItem.newDm:
|
||||
showDialog(context: context, builder: (_) => const NewDmDialog());
|
||||
case _SpaceMenuItem.createGroup:
|
||||
showDialog(
|
||||
context: context, builder: (_) => const CreateJoinDialog());
|
||||
case _SpaceMenuItem.createRoom:
|
||||
showDialog(
|
||||
context: context, builder: (_) => const CreateJoinDialog());
|
||||
@@ -237,6 +442,9 @@ class _Header extends ConsumerWidget {
|
||||
value: _SpaceMenuItem.newDm,
|
||||
child: _MenuRow(
|
||||
Icons.chat_bubble_outline_rounded, 'Neue Unterhaltung', pt)),
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.createGroup,
|
||||
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
|
||||
];
|
||||
} else if (spaceId == 'rooms') {
|
||||
return [
|
||||
@@ -270,14 +478,17 @@ class _Header extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for space banner — prefer local override (set immediately on save)
|
||||
// over the state event (only available after next Matrix sync)
|
||||
// Check for banner — space banner for real spaces, server banner otherwise.
|
||||
final bannerOverrides = ref.watch(spaceBannerOverrideProvider);
|
||||
final bannerUrl = isRealSpace && activeSpace != null
|
||||
? (bannerOverrides.containsKey(activeSpace.id)
|
||||
? bannerOverrides[activeSpace.id]
|
||||
: activeSpace.getState(_kSpaceBannerType)?.content['url'] as String?)
|
||||
: null;
|
||||
final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {};
|
||||
String? bannerUrl;
|
||||
if (isRealSpace && activeSpace != null) {
|
||||
bannerUrl = bannerOverrides.containsKey(activeSpace.id)
|
||||
? bannerOverrides[activeSpace.id]
|
||||
: activeSpace.getState(_kSpaceBannerType)?.content['url'] as String?;
|
||||
} else if (client != null) {
|
||||
bannerUrl = serverBanners[client.homeserver.toString()];
|
||||
}
|
||||
final hasBanner = bannerUrl != null && client != null;
|
||||
|
||||
Widget controlsRow = Row(
|
||||
@@ -359,13 +570,9 @@ class _Header extends ConsumerWidget {
|
||||
onPressed: () => handleMenu(_SpaceMenuItem.adminSpace),
|
||||
),
|
||||
if (!isRealSpace)
|
||||
PyrIconBtn(
|
||||
icon: const Icon(Icons.notifications_none_rounded),
|
||||
tooltip: 'Notifications',
|
||||
onPressed: () {},
|
||||
),
|
||||
_NotificationsButton(pt: pt, hasBanner: hasBanner),
|
||||
PopupMenuButton<_SpaceMenuItem>(
|
||||
tooltip: 'Optionen',
|
||||
tooltip: 'Erstellen',
|
||||
color: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
@@ -374,8 +581,8 @@ class _Header extends ConsumerWidget {
|
||||
onSelected: handleMenu,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(Icons.keyboard_arrow_down_rounded,
|
||||
color: hasBanner ? Colors.white70 : pt.fgDim, size: 16),
|
||||
child: Icon(Icons.add_rounded,
|
||||
color: hasBanner ? Colors.white70 : pt.fgDim, size: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -460,6 +667,122 @@ class _MenuRow extends StatelessWidget {
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Notifications button ──────────────────────────────────────────────────────
|
||||
|
||||
/// Glocke im Header: Popup mit allen Räumen, die ungelesene Nachrichten oder
|
||||
/// Mentions haben. Tippen öffnet den jeweiligen Raum.
|
||||
class _NotificationsButton extends ConsumerWidget {
|
||||
final PyramidTheme pt;
|
||||
final bool hasBanner;
|
||||
const _NotificationsButton({required this.pt, required this.hasBanner});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final rooms = ref.watch(roomListProvider).valueOrNull ?? [];
|
||||
final unread = rooms
|
||||
.where((r) =>
|
||||
r.membership == Membership.join &&
|
||||
(r.notificationCount > 0 || r.highlightCount > 0))
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
final hl = b.highlightCount.compareTo(a.highlightCount);
|
||||
if (hl != 0) return hl;
|
||||
return b.notificationCount.compareTo(a.notificationCount);
|
||||
});
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
tooltip: 'Benachrichtigungen',
|
||||
color: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: pt.border)),
|
||||
offset: const Offset(0, 36),
|
||||
onSelected: (roomId) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
},
|
||||
itemBuilder: (_) {
|
||||
if (unread.isEmpty) {
|
||||
return [
|
||||
PopupMenuItem<String>(
|
||||
enabled: false,
|
||||
child: Text('Keine neuen Benachrichtigungen',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 13)),
|
||||
),
|
||||
];
|
||||
}
|
||||
return unread
|
||||
.map((r) => PopupMenuItem<String>(
|
||||
value: r.id,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
r.highlightCount > 0
|
||||
? Icons.alternate_email_rounded
|
||||
: (r.isDirectChat
|
||||
? Icons.chat_bubble_outline_rounded
|
||||
: Icons.tag_rounded),
|
||||
size: 14,
|
||||
color:
|
||||
r.highlightCount > 0 ? pt.danger : pt.fgMuted,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
r.getLocalizedDisplayname(),
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: r.highlightCount > 0 ? pt.danger : pt.accent,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'${r.notificationCount > 0 ? r.notificationCount : r.highlightCount}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Icon(Icons.notifications_none_rounded,
|
||||
size: 18, color: hasBanner ? Colors.white70 : pt.fgDim),
|
||||
if (unread.isNotEmpty)
|
||||
Positioned(
|
||||
top: -1,
|
||||
right: -1,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.danger,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: pt.bg1, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Filter input ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _FilterInput extends StatelessWidget {
|
||||
@@ -534,6 +857,106 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
|
||||
// Local order override after drag-and-drop (room IDs in order per category key)
|
||||
final Map<String, List<String>> _localOrder = {};
|
||||
|
||||
// Joinable channels discovered via the space hierarchy (not yet a member).
|
||||
List<_JoinableRoom> _joinable = [];
|
||||
final Set<String> _joining = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final client = ref.read(matrixClientProvider).valueOrNull;
|
||||
if (client != null) _loadHierarchy(client);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _SpaceRoomsList old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.spaceId != widget.spaceId) {
|
||||
setState(() => _joinable = []);
|
||||
final client = ref.read(matrixClientProvider).valueOrNull;
|
||||
if (client != null) _loadHierarchy(client);
|
||||
}
|
||||
}
|
||||
|
||||
/// Queries the server-side space hierarchy and keeps only rooms the user is
|
||||
/// not yet a member of — these are the channels they can still join.
|
||||
Future<void> _loadHierarchy(Client client) async {
|
||||
try {
|
||||
final resp = await client.getSpaceHierarchy(
|
||||
widget.spaceId,
|
||||
maxDepth: 3,
|
||||
suggestedOnly: false,
|
||||
);
|
||||
final joinable = <_JoinableRoom>[];
|
||||
for (final chunk in resp.rooms) {
|
||||
if (chunk.roomId == widget.spaceId) continue;
|
||||
if (chunk.roomType == 'm.space') continue; // skip sub-spaces
|
||||
// Keep every channel the hierarchy reports. The build method filters out
|
||||
// the ones already rendered in the joined sections — relying on local
|
||||
// membership here is unreliable because Continuwuity's sync lags behind.
|
||||
joinable.add(_JoinableRoom(
|
||||
roomId: chunk.roomId,
|
||||
name: chunk.name ?? chunk.canonicalAlias ?? chunk.roomId,
|
||||
isVoice: chunk.roomType == _kVoiceRoomType,
|
||||
memberCount: chunk.numJoinedMembers,
|
||||
));
|
||||
}
|
||||
if (mounted) setState(() => _joinable = joinable);
|
||||
} catch (_) {
|
||||
// Hierarchy endpoint unavailable / no permission — silently ignore.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _joinAndEnter(Client client, _JoinableRoom jr) async {
|
||||
setState(() => _joining.add(jr.roomId));
|
||||
try {
|
||||
await client.joinRoomById(jr.roomId).timeout(const Duration(seconds: 20));
|
||||
// Give the sync a brief chance to deliver the room object, but don't block
|
||||
// on it — the local membership may lag behind the server.
|
||||
Room? room = client.getRoomById(jr.roomId);
|
||||
if (room == null) {
|
||||
try {
|
||||
await client.onSync.stream
|
||||
.firstWhere((_) => client.getRoomById(jr.roomId) != null)
|
||||
.timeout(const Duration(seconds: 4));
|
||||
} catch (_) {}
|
||||
room = client.getRoomById(jr.roomId);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _joining.remove(jr.roomId));
|
||||
if (jr.isVoice) {
|
||||
// LiveKit only needs the room id + a display name, so connect even if the
|
||||
// matrix room object hasn't synced locally yet.
|
||||
final call = ref.read(callStateProvider);
|
||||
if (call.isActive) await call.hangUp();
|
||||
ref.read(activeVoiceRoomIdProvider.notifier).state = jr.roomId;
|
||||
await call.startCall(
|
||||
roomName: jr.roomId,
|
||||
roomDisplayName: room?.getLocalizedDisplayname() ?? jr.name,
|
||||
identity: client.userID ?? '',
|
||||
audioOnly: true,
|
||||
voiceChannel: true,
|
||||
matrixClient: client,
|
||||
matrixRoomId: jr.roomId,
|
||||
);
|
||||
} else if (room != null) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = room.id;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _joining.remove(jr.roomId));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content:
|
||||
Text('Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'),
|
||||
backgroundColor: widget.pt.danger,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyReorder(
|
||||
Room space, String categoryKey, List<SpaceChild> newOrder) async {
|
||||
try {
|
||||
@@ -600,6 +1023,10 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
|
||||
.where((r) => r.membership == Membership.invite)
|
||||
.toList();
|
||||
|
||||
// IDs already rendered in the joined/invited sections — used to avoid showing
|
||||
// them again under "available channels" from the hierarchy.
|
||||
final shownIds = <String>{...invites.map((r) => r.id)};
|
||||
|
||||
void selectRoom(Room room) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = room.id;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
@@ -643,6 +1070,7 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
|
||||
.where((r) =>
|
||||
r.membership != Membership.leave && !r.isSpace)
|
||||
.toList();
|
||||
shownIds.addAll(catChildren.map((r) => r.id));
|
||||
|
||||
final filtered = catChildren
|
||||
.where((r) => widget.filter.isEmpty ||
|
||||
@@ -677,6 +1105,7 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
|
||||
.map((c) => client.getRoomById(c.roomId!))
|
||||
.whereType<Room>()
|
||||
.toList();
|
||||
shownIds.addAll(directRooms.map((r) => r.id));
|
||||
|
||||
// Apply local order override if present
|
||||
final localOrd = _localOrder['__direct__'];
|
||||
@@ -698,6 +1127,14 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
|
||||
.contains(widget.filter.toLowerCase()))
|
||||
.toList();
|
||||
|
||||
// Joinable channels from the hierarchy that aren't already listed above.
|
||||
final visibleJoinable = _joinable
|
||||
.where((jr) =>
|
||||
!shownIds.contains(jr.roomId) &&
|
||||
(widget.filter.isEmpty ||
|
||||
jr.name.toLowerCase().contains(widget.filter.toLowerCase())))
|
||||
.toList();
|
||||
|
||||
// Build the complete list
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||
@@ -776,9 +1213,28 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
|
||||
);
|
||||
}),
|
||||
],
|
||||
// Joinable channels from the space hierarchy (not yet a member)
|
||||
if (visibleJoinable.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 2),
|
||||
child: Text('VERFÜGBARE KANÄLE',
|
||||
style: TextStyle(
|
||||
color: widget.pt.fgDim,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6)),
|
||||
),
|
||||
...visibleJoinable.map((jr) => _JoinableRoomItem(
|
||||
jr: jr,
|
||||
joining: _joining.contains(jr.roomId),
|
||||
pt: widget.pt,
|
||||
onJoin: () => _joinAndEnter(client, jr),
|
||||
)),
|
||||
],
|
||||
if (filteredDirect.isEmpty &&
|
||||
categoryWidgets.isEmpty &&
|
||||
invites.isEmpty)
|
||||
invites.isEmpty &&
|
||||
visibleJoinable.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
@@ -791,6 +1247,110 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Joinable room (from space hierarchy) ─────────────────────────────────────
|
||||
|
||||
class _JoinableRoom {
|
||||
final String roomId;
|
||||
final String name;
|
||||
final bool isVoice;
|
||||
final int memberCount;
|
||||
const _JoinableRoom({
|
||||
required this.roomId,
|
||||
required this.name,
|
||||
required this.isVoice,
|
||||
required this.memberCount,
|
||||
});
|
||||
}
|
||||
|
||||
class _JoinableRoomItem extends StatefulWidget {
|
||||
final _JoinableRoom jr;
|
||||
final bool joining;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onJoin;
|
||||
const _JoinableRoomItem({
|
||||
required this.jr,
|
||||
required this.joining,
|
||||
required this.pt,
|
||||
required this.onJoin,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_JoinableRoomItem> createState() => _JoinableRoomItemState();
|
||||
}
|
||||
|
||||
class _JoinableRoomItemState extends State<_JoinableRoomItem> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final jr = widget.jr;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.joining ? null : widget.onJoin,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
margin: const EdgeInsets.symmetric(vertical: 1),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: pt.rowPadX, vertical: pt.rowPadY),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? pt.bgHover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: Icon(
|
||||
jr.isVoice ? Icons.mic_rounded : Icons.tag_rounded,
|
||||
size: 14,
|
||||
color: pt.fgDim,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
jr.name,
|
||||
style: TextStyle(
|
||||
color: pt.fgMuted,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (widget.joining)
|
||||
const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? pt.accent : pt.bg3,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'Beitreten',
|
||||
style: TextStyle(
|
||||
color: _hovered ? pt.accentFg : pt.fgMuted,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Category header ───────────────────────────────────────────────────────────
|
||||
|
||||
class _CategoryHeader extends StatefulWidget {
|
||||
@@ -1258,6 +1818,67 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Touch-friendly action menu — opened via long-press (the hover menu is not
|
||||
/// reachable on touch devices).
|
||||
void _showContextMenu() {
|
||||
final pt = widget.pt;
|
||||
final room = widget.room;
|
||||
final isDm = room.isDirectChat;
|
||||
|
||||
Widget action(IconData icon, String label, VoidCallback onTap,
|
||||
{bool danger = false}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, size: 20, color: danger ? pt.danger : pt.fgMuted),
|
||||
title: Text(label,
|
||||
style: TextStyle(
|
||||
color: danger ? pt.danger : pt.fg, fontSize: 14)),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onTap();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: pt.bg2,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(room.getLocalizedDisplayname(),
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Divider(height: 1, color: pt.border),
|
||||
if (!isDm) ...[
|
||||
action(Icons.person_add_outlined, 'Nutzer einladen', _inviteUser),
|
||||
action(Icons.settings_rounded, 'Einstellungen', _showRoomSettings),
|
||||
],
|
||||
action(
|
||||
Icons.exit_to_app_rounded,
|
||||
isDm ? 'Chat verlassen' : 'Raum verlassen',
|
||||
isDm ? _leaveDm : _leaveRoom,
|
||||
danger: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
@@ -1279,6 +1900,7 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
onLongPress: _showContextMenu,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (active)
|
||||
@@ -1693,7 +2315,15 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> {
|
||||
Color? _dotColor() {
|
||||
final p = _presence;
|
||||
if (p == null) return null;
|
||||
if (p.currentlyActive == true) return PyramidColors.online;
|
||||
// Server-Presence kann veraltet sein (z.B. nie als offline gemeldet).
|
||||
// Nur als online werten, wenn die letzte Aktivität wirklich frisch ist.
|
||||
final last = p.lastActiveTimestamp;
|
||||
final fresh = last != null &&
|
||||
DateTime.now().difference(last) < const Duration(minutes: 5);
|
||||
if (p.presence == PresenceType.online &&
|
||||
(p.currentlyActive == true || fresh)) {
|
||||
return PyramidColors.online;
|
||||
}
|
||||
if (p.presence == PresenceType.unavailable) return PyramidColors.away;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,22 @@ final roomListProvider = StreamProvider<List<Room>>((ref) async* {
|
||||
}
|
||||
});
|
||||
|
||||
final dmUnreadCountProvider = StreamProvider<int>((ref) async* {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
|
||||
yield _countDmUnread(client);
|
||||
|
||||
await for (final _ in client.onSync.stream) {
|
||||
yield _countDmUnread(client);
|
||||
}
|
||||
});
|
||||
|
||||
int _countDmUnread(Client client) {
|
||||
return client.rooms
|
||||
.where((r) => !r.isSpace && r.isDirectChat && r.notificationCount > 0)
|
||||
.length;
|
||||
}
|
||||
|
||||
List<Room> _sortedRooms(Client client) {
|
||||
final rooms = client.rooms
|
||||
.where((r) => !r.isSpace)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
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/hover_region.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/pyramid_logo.dart';
|
||||
|
||||
final _ownProfileProvider = FutureProvider.autoDispose<Profile?>((ref) async {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
final userId = client.userID;
|
||||
if (userId == null) return null;
|
||||
try {
|
||||
return await client.getProfileFromUserId(userId);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
class UserPanel extends ConsumerWidget {
|
||||
const UserPanel({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final clientAsync = ref.watch(matrixClientProvider);
|
||||
final profile = ref.watch(_ownProfileProvider).valueOrNull;
|
||||
|
||||
final client = clientAsync.valueOrNull;
|
||||
final fallbackName = client?.userID?.split(':').first.replaceFirst('@', '') ?? '...';
|
||||
final displayName = profile?.displayName ?? fallbackName;
|
||||
final avatarUri = profile?.avatarUrl;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: _ProfileRow(
|
||||
pt: pt,
|
||||
client: client,
|
||||
avatarUri: avatarUri,
|
||||
displayName: displayName,
|
||||
ref: ref,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileRow extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final Client? client;
|
||||
final Uri? avatarUri;
|
||||
final String displayName;
|
||||
final WidgetRef ref;
|
||||
|
||||
const _ProfileRow({
|
||||
required this.pt,
|
||||
required this.client,
|
||||
required this.avatarUri,
|
||||
required this.displayName,
|
||||
required this.ref,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
if (client != null && avatarUri != null)
|
||||
MxcAvatar(
|
||||
mxcUri: avatarUri!,
|
||||
client: client!,
|
||||
size: 32,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
placeholder: (_) => _InitialAvatar(name: displayName, radius: 4),
|
||||
)
|
||||
else
|
||||
_InitialAvatar(name: displayName, radius: 4),
|
||||
Positioned(
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
child: PresenceDot(status: 'online', size: 10, borderColor: pt.bg2),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'online',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.settings_rounded),
|
||||
tooltip: 'Einstellungen',
|
||||
onPressed: () => ref.read(activeModalProvider.notifier).state = ModalKind.settings,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InitialAvatar extends StatelessWidget {
|
||||
final String name;
|
||||
final double radius;
|
||||
final double size;
|
||||
const _InitialAvatar({required this.name, required this.radius, this.size = 32});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFFF6B9D), Color(0xFFC471F5)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: size * 0.44,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user