Files
alvocool 38881d1fe1 Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 10:15:30 +03:00

282 lines
8.2 KiB
Dart

import 'package:flutter/material.dart';
import '../../about/internal/application/TextType.dart';
import '../../utils/Colors.dart';
import '../text/Text.dart';
class NavItemData {
final IconData icon;
final String label;
final int? badge;
final VoidCallback onTap;
final bool selected;
const NavItemData({
required this.icon,
required this.label,
required this.onTap,
this.badge,
this.selected = false,
});
}
/// Left navigation rail / sidebar. [expanded] shows labels; otherwise a
/// compact icon rail.
class SideNav extends StatelessWidget {
final bool expanded;
final List<NavItemData> items;
final ThemeMode themeMode;
final Future<void> Function() onCycleTheme;
final VoidCallback onNewServer;
const SideNav({
super.key,
required this.expanded,
required this.items,
required this.themeMode,
required this.onCycleTheme,
required this.onNewServer,
});
(IconData, String) get _themeIcon => switch (themeMode) {
ThemeMode.dark => (Icons.dark_mode, 'Dark'),
ThemeMode.light => (Icons.light_mode, 'Light'),
ThemeMode.system => (Icons.brightness_auto, 'System'),
};
@override
Widget build(BuildContext context) {
final jp = context.jp;
final (themeI, themeLabel) = _themeIcon;
return Container(
width: expanded ? 236 : 78,
decoration: BoxDecoration(
color: jp.surface,
border: Border(right: BorderSide(color: jp.border)),
),
child: SafeArea(
right: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.fromLTRB(expanded ? 20 : 0, 20, 0, 20),
child: Row(
mainAxisAlignment: expanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
gradient: PeachTheme.brandGradient,
borderRadius: BorderRadius.circular(11),
boxShadow: PeachTheme.glow(
PeachTheme.seed,
opacity: 0.5,
blur: 18,
),
),
child: const Icon(
Icons.rocket_launch,
color: Colors.white,
size: 20,
),
),
if (expanded) ...[
const SizedBox(width: 12),
text(
'Peach',
20,
TextType.Bold,
color: jp.text,
weight: FontWeight.w800,
),
],
],
),
),
Expanded(
child: ListView(
padding: EdgeInsets.symmetric(horizontal: expanded ? 12 : 10),
children: [for (final it in items) _navItem(context, it)],
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: expanded ? 12 : 10),
child: Column(
children: [
_footerButton(
context,
themeI,
themeLabel,
(_) => onCycleTheme(),
),
const SizedBox(height: 6),
_newServerButton(context),
const SizedBox(height: 14),
],
),
),
],
),
),
);
}
Widget _navItem(BuildContext context, NavItemData it) {
final jp = context.jp;
final inner = Container(
padding: EdgeInsets.symmetric(
horizontal: expanded ? 14 : 0,
vertical: 12,
),
decoration: BoxDecoration(
color: it.selected
? PeachTheme.seed.withValues(alpha: 0.13)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: expanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
Icon(
it.icon,
size: 20,
color: it.selected ? PeachTheme.seed : jp.textDim,
),
if (expanded) ...[
const SizedBox(width: 12),
Expanded(
child: text(
it.label,
14,
TextType.Regular,
color: it.selected ? jp.text : jp.textDim,
overflow: TextOverflow.ellipsis,
weight: it.selected ? FontWeight.w700 : FontWeight.w600,
),
),
if (it.badge != null && it.badge! > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(
color: jp.surface2,
borderRadius: BorderRadius.circular(20),
),
child: text(
'${it.badge}',
11,
TextType.Regular,
color: jp.textDim,
weight: FontWeight.w700,
),
),
],
],
),
);
// A premium left accent bar marks the selected item.
final content = Container(
margin: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 3,
height: it.selected ? 22 : 0,
margin: EdgeInsets.only(right: expanded ? 8 : 0),
decoration: BoxDecoration(
color: PeachTheme.seed,
borderRadius: BorderRadius.circular(3),
),
),
Expanded(child: inner),
],
),
);
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: it.onTap,
child: expanded ? content : Tooltip(message: it.label, child: content),
);
}
Widget _footerButton(
BuildContext context,
IconData icon,
String label,
void Function(BuildContext) onTap,
) {
final jp = context.jp;
final w = InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => onTap(context),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: expanded ? 14 : 0,
vertical: 12,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: jp.border),
),
child: Row(
mainAxisAlignment: expanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
Icon(icon, size: 19, color: jp.textDim),
if (expanded) ...[
const SizedBox(width: 12),
text(
'Theme: $label',
13,
TextType.Regular,
color: jp.textDim,
weight: FontWeight.w400,
),
],
],
),
),
);
return expanded ? w : Tooltip(message: 'Theme: $label', child: w);
}
Widget _newServerButton(BuildContext context) {
if (!expanded) {
return Tooltip(
message: 'New server',
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onNewServer,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
gradient: PeachTheme.brandGradient,
borderRadius: BorderRadius.circular(12),
boxShadow: PeachTheme.glow(
PeachTheme.seed,
opacity: 0.5,
blur: 16,
),
),
child: const Icon(Icons.add, color: Colors.white),
),
),
);
}
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onNewServer,
icon: const Icon(Icons.add),
label: const Text('New server'),
),
);
}
}