Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
281
lib/Peach/designs/navigation/SideNav.dart
Normal file
281
lib/Peach/designs/navigation/SideNav.dart
Normal file
@@ -0,0 +1,281 @@
|
||||
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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user