- 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]>
624 lines
23 KiB
Dart
624 lines
23 KiB
Dart
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),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|