feat(ui): untere Leiste (Chats/Server/Du) als Option, Server-Uebersicht, Du-Tab, Feinschliff
- Einstellung 'Navigation: Seitenmenue | Untere Leiste' (Handy, Standard bleibt Seitenmenue): Tabs Chats/Server/Du, Server-Uebersicht als Karten, Du-Tab mit Profil + Einstellungen, Zurueck-Taste ueber die Tabs - CallDock als eigenes Widget (Anrufleiste auch ueber der Tab-Leiste) - Sprachkanaele am Handy als Kachel mit 'wer ist drin' - Suchfeld mit Lupe, 'Kanaele durchsuchen' im Server, Filter-Chips am PC umbrechend, '#' nur bei Server-Kanaelen, englische Reste entfernt - Anrufansicht merkt sich Bubbles/Kacheln, Bubbles mit leichtem Glanz - UI-Tour: Gruppenanruf (echter zweiter LiveKit-Teilnehmer), Bubbles mit 5/7 Personen, untere Leiste; Version 0.1.42 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
de24053167
commit
391ebf0ffa
@@ -18,6 +18,8 @@ import 'package:pyramid/features/members/members_panel.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
||||
import 'package:pyramid/features/spaces/spaces_rail.dart';
|
||||
import 'package:pyramid/layout/mobile_page_stack.dart';
|
||||
import 'package:pyramid/layout/mobile_tabs.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
|
||||
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/search_modal.dart';
|
||||
@@ -395,11 +397,17 @@ class _MobileShell extends ConsumerWidget {
|
||||
: activeRoomId != null
|
||||
? ChatView(roomId: activeRoomId!)
|
||||
: null;
|
||||
final bottomNav = ref.watch(bottomNavProvider);
|
||||
final tab = ref.watch(mobileTabProvider);
|
||||
final body = MobilePageStack(
|
||||
base: const RoomsPanel(),
|
||||
base: bottomNav ? const MobileTabsBase() : const RoomsPanel(),
|
||||
front: front,
|
||||
background: pt.bg1,
|
||||
);
|
||||
// Untere Leiste: Zurück führt erst über die Tabs (Server → Übersicht →
|
||||
// Chats), bevor die App geschlossen wird.
|
||||
final tabsAtRoot = !bottomNav ||
|
||||
(tab == MobileTab.chats);
|
||||
|
||||
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
|
||||
final overlayStyle = (pt.isDark
|
||||
@@ -413,7 +421,10 @@ class _MobileShell extends ConsumerWidget {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: overlayStyle,
|
||||
child: PopScope(
|
||||
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
|
||||
canPop: activeRoomId == null &&
|
||||
viewMode == ViewMode.chat &&
|
||||
!membersOpen &&
|
||||
tabsAtRoot,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) {
|
||||
if (membersOpen) {
|
||||
@@ -422,6 +433,8 @@ class _MobileShell extends ConsumerWidget {
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
} else if (activeRoomId != null) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = null;
|
||||
} else if (bottomNav) {
|
||||
handleMobileTabBack(ref);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -429,11 +442,13 @@ class _MobileShell extends ConsumerWidget {
|
||||
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
|
||||
// geht nahtlos in sie über.
|
||||
backgroundColor: pt.bg1,
|
||||
drawer: Drawer(
|
||||
width: 80,
|
||||
backgroundColor: pt.bg0,
|
||||
child: const SafeArea(child: SpacesRail()),
|
||||
),
|
||||
drawer: bottomNav
|
||||
? null
|
||||
: Drawer(
|
||||
width: 80,
|
||||
backgroundColor: pt.bg0,
|
||||
child: const SafeArea(child: SpacesRail()),
|
||||
),
|
||||
// SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
|
||||
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
|
||||
body: SafeArea(child: Stack(
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
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/room_kinds.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||
import 'package:pyramid/widgets/create_join_dialog.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
|
||||
/// Handy-Navigation mit unterer Leiste (Einstellung „Navigation: Untere
|
||||
/// Leiste“, Bernd 2026-10-09 zum Vergleich mit dem Seitenmenü):
|
||||
/// **Chats** (Direktnachrichten + Gruppen), **Server** (Übersicht → Kanäle),
|
||||
/// **Du** (Profil + Einstellungen). Chat und Anruf gleiten wie gewohnt
|
||||
/// darüber; die Leiste liegt darunter und verschwindet mit.
|
||||
enum MobileTab { chats, servers, me }
|
||||
|
||||
final mobileTabProvider = StateProvider<MobileTab>((ref) => MobileTab.chats);
|
||||
|
||||
/// Zuletzt geöffneter Server – „Server“ öffnet wieder dort.
|
||||
final _lastServerProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
bool _isRealSpace(String? id) => id != null && id != 'dms' && id != 'rooms';
|
||||
|
||||
/// Wechselt den Tab (und die passende Liste).
|
||||
void selectMobileTab(WidgetRef ref, MobileTab tab) {
|
||||
final current = ref.read(mobileTabProvider);
|
||||
final space = ref.read(activeSpaceIdProvider);
|
||||
if (_isRealSpace(space)) ref.read(_lastServerProvider.notifier).state = space;
|
||||
switch (tab) {
|
||||
case MobileTab.chats:
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
case MobileTab.servers:
|
||||
// Nochmal auf „Server“ tippen = zurück zur Übersicht.
|
||||
ref.read(activeSpaceIdProvider.notifier).state =
|
||||
current == MobileTab.servers ? null : ref.read(_lastServerProvider);
|
||||
case MobileTab.me:
|
||||
break;
|
||||
}
|
||||
ref.read(mobileTabProvider.notifier).state = tab;
|
||||
}
|
||||
|
||||
/// Zurück-Taste mit unterer Leiste: Server → Übersicht → Chats.
|
||||
/// Liefert `true`, wenn sie etwas getan hat.
|
||||
bool handleMobileTabBack(WidgetRef ref) {
|
||||
final tab = ref.read(mobileTabProvider);
|
||||
if (tab == MobileTab.servers &&
|
||||
_isRealSpace(ref.read(activeSpaceIdProvider))) {
|
||||
ref.read(activeSpaceIdProvider.notifier).state = null;
|
||||
ref.read(_lastServerProvider.notifier).state = null;
|
||||
return true;
|
||||
}
|
||||
if (tab != MobileTab.chats) {
|
||||
selectMobileTab(ref, MobileTab.chats);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Grundseite unter Chat/Anruf: Inhalt des Tabs, Anruf-Leiste, Tab-Leiste.
|
||||
class MobileTabsBase extends ConsumerWidget {
|
||||
const MobileTabsBase({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final tab = ref.watch(mobileTabProvider);
|
||||
final space = ref.watch(activeSpaceIdProvider);
|
||||
final Widget content = switch (tab) {
|
||||
MobileTab.chats => const RoomsPanel(key: ValueKey('tab-chats')),
|
||||
MobileTab.servers => _isRealSpace(space)
|
||||
? RoomsPanel(key: ValueKey('tab-server-$space'))
|
||||
: const ServerOverview(key: ValueKey('tab-servers')),
|
||||
MobileTab.me => const MeTab(key: ValueKey('tab-me')),
|
||||
};
|
||||
return ColoredBox(
|
||||
color: pt.bg1,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: motionDuration(context, 220),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, anim) => FadeTransition(
|
||||
opacity: anim,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 0.015),
|
||||
end: Offset.zero,
|
||||
).animate(anim),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
const CallDock(),
|
||||
const _TabBar(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabBar extends ConsumerWidget {
|
||||
const _TabBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final tab = ref.watch(mobileTabProvider);
|
||||
final chatUnread = ref.watch(dmUnreadCountProvider).valueOrNull ?? 0;
|
||||
final serverUnread = ref.watch(_serverUnreadProvider);
|
||||
|
||||
Widget item(MobileTab t, IconData icon, IconData activeIcon, String label,
|
||||
int badge) {
|
||||
final selected = tab == t;
|
||||
return Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: label,
|
||||
child: SpringPress(
|
||||
child: InkResponse(
|
||||
onTap: () => selectMobileTab(ref, t),
|
||||
radius: 40,
|
||||
highlightShape: BoxShape.rectangle,
|
||||
containedInkWell: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: motionDuration(context, 240),
|
||||
curve: springyCurve,
|
||||
width: selected ? 60 : 44,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? pt.accentSoft : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Icon(selected ? activeIcon : icon,
|
||||
size: 22,
|
||||
color: selected ? pt.accent : pt.fgMuted),
|
||||
),
|
||||
if (badge > 0)
|
||||
Positioned(
|
||||
right: selected ? 6 : 0,
|
||||
top: -3,
|
||||
child: SpringBump(
|
||||
value: badge,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minWidth: 18),
|
||||
height: 18,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 5),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.danger,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(color: pt.bg1, width: 2),
|
||||
),
|
||||
child: Text(badge > 99 ? '99+' : '$badge',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
color: selected ? pt.fg : pt.fgMuted,
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
selected ? FontWeight.w700 : FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg1,
|
||||
border: Border(top: BorderSide(color: pt.border)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
item(MobileTab.chats, Icons.chat_bubble_outline_rounded,
|
||||
Icons.chat_bubble_rounded, 'Chats', chatUnread),
|
||||
item(MobileTab.servers, Icons.dns_outlined, Icons.dns_rounded,
|
||||
'Server', serverUnread),
|
||||
item(MobileTab.me, Icons.person_outline_rounded,
|
||||
Icons.person_rounded, 'Du', 0),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ungelesene Kanäle über alle Server (für das Abzeichen am „Server“-Tab).
|
||||
final _serverUnreadProvider = Provider<int>((ref) {
|
||||
ref.watch(roomListProvider);
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
if (client == null) return 0;
|
||||
final channels = spaceChannelIds(client);
|
||||
return client.rooms
|
||||
.where((r) =>
|
||||
channels.contains(r.id) &&
|
||||
r.membership == Membership.join &&
|
||||
r.notificationCount > 0)
|
||||
.length;
|
||||
});
|
||||
|
||||
/// Übersicht aller Server als Karten (Tab „Server“).
|
||||
class ServerOverview extends ConsumerWidget {
|
||||
const ServerOverview({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
ref.watch(roomListProvider); // bei jeder Synchronisation neu
|
||||
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_TabHeader(
|
||||
title: 'Server',
|
||||
pt: pt,
|
||||
action: IconButton(
|
||||
tooltip: 'Server erstellen oder beitreten',
|
||||
icon: Icon(Icons.add_rounded, color: pt.fgMuted),
|
||||
onPressed: () => showDialog(
|
||||
context: context, builder: (_) => const CreateJoinDialog()),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: spaces.isEmpty || client == null
|
||||
? _EmptyServers(pt: pt)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 16),
|
||||
itemCount: spaces.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, i) =>
|
||||
_ServerCard(space: spaces[i], client: client, pt: pt),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerCard extends ConsumerWidget {
|
||||
final Room space;
|
||||
final Client client;
|
||||
final PyramidTheme pt;
|
||||
const _ServerCard(
|
||||
{required this.space, required this.client, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final name = space.getLocalizedDisplayname();
|
||||
final invited = space.membership == Membership.invite;
|
||||
var channels = 0, unread = 0, inVoice = 0;
|
||||
for (final child in space.spaceChildren) {
|
||||
final id = child.roomId;
|
||||
if (id == null) continue;
|
||||
final r = client.getRoomById(id);
|
||||
if (r == null || r.isSpace) continue;
|
||||
channels++;
|
||||
if (r.membership == Membership.join && r.notificationCount > 0) unread++;
|
||||
if (isVoiceChannelRoom(r)) {
|
||||
inVoice += ref.watch(voiceParticipantsProvider(r.id)).length;
|
||||
}
|
||||
}
|
||||
final subtitle = invited
|
||||
? 'Einladung – antippen zum Ansehen'
|
||||
: [
|
||||
channels == 1 ? '1 Kanal' : '$channels Kanäle',
|
||||
if (unread > 0) '$unread ungelesen',
|
||||
].join(' · ');
|
||||
|
||||
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
|
||||
Widget fallback(BuildContext _) => Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accentSoft,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(initial,
|
||||
style: TextStyle(
|
||||
color: pt.accent, fontSize: 22, fontWeight: FontWeight.w700)),
|
||||
);
|
||||
|
||||
return SpringPress(
|
||||
pressedScale: 0.97,
|
||||
child: Material(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: () {
|
||||
ref.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||
ref.read(activeRoomIdProvider.notifier).state = null;
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: invited ? pt.accent.withAlpha(120) : pt.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
space.avatar == null
|
||||
? Builder(builder: fallback)
|
||||
: MxcAvatar(
|
||||
mxcUri: space.avatar,
|
||||
client: client,
|
||||
size: 52,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
placeholder: fallback,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 16,
|
||||
fontWeight: unread > 0
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: invited ? pt.accent : pt.fgMuted,
|
||||
fontSize: 13)),
|
||||
if (inVoice > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
Icon(Icons.graphic_eq_rounded,
|
||||
size: 14, color: pt.online),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
inVoice == 1
|
||||
? '1 Person im Sprachkanal'
|
||||
: '$inVoice Personen im Sprachkanal',
|
||||
style: TextStyle(
|
||||
color: pt.online,
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right_rounded, color: pt.fgDim),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyServers extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _EmptyServers({required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.dns_outlined, size: 40, color: pt.fgDim),
|
||||
const SizedBox(height: 10),
|
||||
Text('Noch keine Server',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 15)),
|
||||
const SizedBox(height: 4),
|
||||
Text('Server sind wie Discord: Kanäle für Text und Sprache.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 13)),
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.tonalIcon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: pt.accentSoft,
|
||||
foregroundColor: pt.accent,
|
||||
),
|
||||
icon: const Icon(Icons.add_rounded, size: 18),
|
||||
label: const Text('Server erstellen oder beitreten'),
|
||||
onPressed: () => showDialog(
|
||||
context: context, builder: (_) => const CreateJoinDialog()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final PyramidTheme pt;
|
||||
final Widget? action;
|
||||
const _TabHeader({required this.title, required this.pt, this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.only(left: 18, right: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg1,
|
||||
border: Border(bottom: BorderSide(color: pt.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title,
|
||||
style: TextStyle(
|
||||
color: pt.fg, fontSize: 17, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
?action,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _myProfileProvider = FutureProvider.autoDispose<Profile?>((ref) async {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
final id = client.userID;
|
||||
if (id == null) return null;
|
||||
try {
|
||||
return await client.getProfileFromUserId(id);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/// Tab „Du“: eigenes Profil und Einstellungen.
|
||||
class MeTab extends ConsumerWidget {
|
||||
const MeTab({super.key});
|
||||
|
||||
static const _entries = <(String, String, IconData)>[
|
||||
('profile', 'Mein Profil', Icons.person_outline_rounded),
|
||||
('notifications', 'Benachrichtigungen', Icons.notifications_none_rounded),
|
||||
('appearance', 'Erscheinungsbild', Icons.palette_outlined),
|
||||
('voice', 'Sprache & Video', Icons.headphones_rounded),
|
||||
('privacy', 'Datenschutz', Icons.lock_outline_rounded),
|
||||
('sessions', 'Sitzungen', Icons.devices_rounded),
|
||||
('encryption', 'Verschlüsselung', Icons.shield_outlined),
|
||||
('about', 'Über Pyramid', Icons.info_outline_rounded),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
final profile = ref.watch(_myProfileProvider).valueOrNull;
|
||||
final userId = client?.userID ?? '';
|
||||
final name = profile?.displayName ??
|
||||
userId.split(':').first.replaceFirst('@', '');
|
||||
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
|
||||
|
||||
void open(String section) {
|
||||
ref.read(settingsSectionRequestProvider.notifier).state = section;
|
||||
ref.read(activeModalProvider.notifier).state = ModalKind.settings;
|
||||
}
|
||||
|
||||
Widget fallback(BuildContext _) => Container(
|
||||
width: 76,
|
||||
height: 76,
|
||||
decoration: BoxDecoration(color: pt.accentSoft, shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: Text(initial,
|
||||
style: TextStyle(
|
||||
color: pt.accent, fontSize: 32, fontWeight: FontWeight.w700)),
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_TabHeader(title: 'Du', pt: pt),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 16, 12, 16),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
profile?.avatarUrl == null || client == null
|
||||
? Builder(builder: fallback)
|
||||
: MxcAvatar(
|
||||
mxcUri: profile!.avatarUrl,
|
||||
client: client,
|
||||
size: 76,
|
||||
placeholder: fallback,
|
||||
),
|
||||
Positioned(
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.online,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: pt.bg2, width: 3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 2),
|
||||
Text(userId,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style:
|
||||
TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: pt.fg,
|
||||
side: BorderSide(color: pt.border),
|
||||
shape: const StadiumBorder(),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
icon: const Icon(Icons.edit_rounded, size: 16),
|
||||
label: const Text('Profil bearbeiten'),
|
||||
onPressed: () => open('profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 1; i < _entries.length; i++) ...[
|
||||
if (i > 1)
|
||||
Divider(height: 1, indent: 56, color: pt.border),
|
||||
ListTile(
|
||||
leading: Icon(_entries[i].$3, color: pt.fgMuted),
|
||||
title: Text(_entries[i].$2,
|
||||
style: TextStyle(color: pt.fg, fontSize: 15)),
|
||||
trailing: Icon(Icons.chevron_right_rounded,
|
||||
color: pt.fgDim),
|
||||
onTap: () => open(_entries[i].$1),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user