app shell

This commit is contained in:
2026-05-24 09:49:07 +02:00
parent 31066a4d8f
commit 415811f592
2 changed files with 388 additions and 99 deletions

View File

@@ -1,97 +1,375 @@
import 'package:flutter/material.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:go_router/go_router.dart';
// ==========================================
// 1. IL GUSCIO (QUELLO CHE PASSI AL ROUTER)
// ==========================================
class AppShell extends StatelessWidget {
final Widget child;
const AppShell({super.key, required this.child});
// Calcoliamo l'indice attivo in base all'URL corrente!
int _calculateSelectedIndex(BuildContext context) {
final String location = GoRouterState.of(context).uri.path;
if (location.startsWith('/master-data')) return 1;
if (location.startsWith('/settings')) return 2;
return 0; // Default: Dashboard
}
void _onItemTapped(int index, BuildContext context) {
switch (index) {
case 0:
context.go('/');
break;
case 1:
context.go('/master-data');
break;
case 2:
context.go('/settings');
break;
}
}
@override
Widget build(BuildContext context) {
final currentIndex = _calculateSelectedIndex(context);
// Breakpoint: se lo schermo è più largo di 600px, usiamo la Rail laterale
final isDesktop = MediaQuery.sizeOf(context).width >= 600;
// Breakpoint a 900px: sotto è Mobile/Tablet (Drawer), sopra è Desktop (Sidebar)
final isDesktop = MediaQuery.sizeOf(context).width >= 900;
final currentPath = GoRouterState.of(context).uri.path;
return Scaffold(
// Su mobile usiamo un'AppBar minimale per avere il bottone "Hamburger" nativo
appBar: isDesktop
? null
: AppBar(
title: const Text(
"FLUX",
style: TextStyle(fontWeight: FontWeight.bold),
),
centerTitle: true,
elevation: 0,
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
),
drawer: isDesktop
? null
: Drawer(
// Su mobile inietta il menu qui!
child: AppMenu(currentPath: currentPath, isDrawer: true),
),
body: isDesktop
? Row(
children: [
NavigationRail(
selectedIndex: currentIndex,
onDestinationSelected: (index) =>
_onItemTapped(index, context),
labelType: NavigationRailLabelType.all,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: Text(context.l10n.commonDashboard),
),
NavigationRailDestination(
icon: Icon(Icons.folder_special_outlined),
selectedIcon: Icon(Icons.folder_special),
label: Text(context.l10n.commonMasterData),
),
NavigationRailDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: Text(context.l10n.commonSettings),
),
],
),
// Su desktop inietta il menu a sinistra!
AppMenu(currentPath: currentPath, isDrawer: false),
const VerticalDivider(thickness: 1, width: 1),
// Il contenuto della pagina
Expanded(child: child),
],
)
: child, // Su mobile il contenuto prende tutto lo schermo...
// ... e mettiamo la barra in basso!
bottomNavigationBar: isDesktop
? null
: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (index) => _onItemTapped(index, context),
destinations: [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: context.l10n.commonDashboard,
: child, // Su mobile il child prende tutto lo schermo sotto l'AppBar
);
}
}
class AppMenu extends StatefulWidget {
final String currentPath; // Lo usiamo ancora per capire cosa accendere
final bool isDrawer;
const AppMenu({super.key, required this.currentPath, required this.isDrawer});
@override
State<AppMenu> createState() => _AppMenuState();
}
class _AppMenuState extends State<AppMenu> {
bool _isCollapsed = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bool effectivelyCollapsed = _isCollapsed && !widget.isDrawer;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: effectivelyCollapsed ? 72 : 260,
child: SafeArea(
child: Column(
children: [
// --- HEADER ---
Container(
height: 80,
padding: const EdgeInsets.symmetric(horizontal: 20.0),
alignment: effectivelyCollapsed
? Alignment.center
: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bolt, color: theme.colorScheme.primary, size: 32),
if (!effectivelyCollapsed) ...[
const SizedBox(width: 12),
Text(
"FLUX",
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
NavigationDestination(
icon: Icon(Icons.folder_special_outlined),
selectedIcon: Icon(Icons.folder_special),
label: context.l10n.commonMasterData,
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: context.l10n.commonSettings,
),
],
],
),
),
// --- VOCI DI MENU ---
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: SizedBox(
width: effectivelyCollapsed ? 72 : 260,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
children: [
_buildRouteItem(
title: context.l10n.commonDashboard,
icon: Icons.dashboard_outlined,
routeName: Routes.home, // <--- Usiamo la tua costante!
pathToCheck:
'/', // Il path da controllare per colorarlo
isCollapsed: effectivelyCollapsed,
),
const SizedBox(height: 8),
// --- IL MENU GERARCHICO (ANAGRAFICHE) ---
_buildHierarchicalItem(
title: context.l10n.commonMasterData,
icon: Icons.folder_special_outlined,
basePathToCheck:
'/master-data', // Se il path inizia così, espandi
isCollapsed: effectivelyCollapsed,
subItems: [
_SubMenuItem(
"Clienti",
Routes.customers,
'/master-data/customers',
),
_SubMenuItem(
"Fornitori",
Routes.providers,
'/master-data/providers',
),
_SubMenuItem(
"Prodotti",
Routes.products,
'/master-data/products',
),
_SubMenuItem(
"Staff",
Routes.staff,
'/master-data/staff',
),
],
),
const SizedBox(height: 8),
_buildRouteItem(
title: context.l10n.commonSettings,
icon: Icons.settings_outlined,
routeName: Routes.settings,
pathToCheck: '/settings',
isCollapsed: effectivelyCollapsed,
),
],
),
),
),
),
// --- PULSANTE TOGGLE (Solo Desktop) ---
if (!widget.isDrawer)
Padding(
padding: const EdgeInsets.all(8.0),
child: IconButton(
tooltip: _isCollapsed ? 'Espandi Menu' : 'Riduci Menu',
icon: Icon(
_isCollapsed
? Icons.keyboard_double_arrow_right
: Icons.keyboard_double_arrow_left,
color: theme.iconTheme.color?.withValues(alpha: 0.5),
),
onPressed: () {
setState(() {
_isCollapsed = !_isCollapsed;
});
},
),
),
],
),
),
);
}
// ==========================================
// WIDGET HELPER AGGIORNATI
// ==========================================
Widget _buildRouteItem({
required String title,
required IconData icon,
required String routeName,
required String pathToCheck,
required bool isCollapsed,
}) {
final isSelected = widget.currentPath == pathToCheck;
final theme = Theme.of(context);
if (isCollapsed) {
return Tooltip(
message: title,
preferBelow: false,
child: InkWell(
onTap: () {
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(routeName); // <--- goNamed!
},
borderRadius: BorderRadius.circular(8),
child: Container(
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: isSelected ? theme.colorScheme.primary : null,
),
),
),
);
}
return ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
selectedTileColor: theme.colorScheme.primaryContainer.withValues(
alpha: 0.4,
),
selected: isSelected,
leading: Icon(icon, color: isSelected ? theme.colorScheme.primary : null),
title: Text(
title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.clip,
),
onTap: () {
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(routeName); // <--- goNamed!
},
);
}
Widget _buildHierarchicalItem({
required String title,
required IconData icon,
required String basePathToCheck,
required bool isCollapsed,
required List<_SubMenuItem> subItems,
}) {
final isSelected = widget.currentPath.startsWith(basePathToCheck);
final theme = Theme.of(context);
if (isCollapsed) {
return PopupMenuButton<String>(
tooltip: title,
offset: const Offset(60, 0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (routeName) {
// Il routeName arriva dal value del menu
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(routeName); // <--- goNamed!
},
itemBuilder: (context) => subItems
.map(
(item) => PopupMenuItem(
value: item
.routeName, // Passiamo il nome della rotta (Routes.customers)
child: Text(
item.title,
style: TextStyle(
fontWeight: widget.currentPath == item.pathToCheck
? FontWeight.bold
: FontWeight.normal,
color: widget.currentPath == item.pathToCheck
? theme.colorScheme.primary
: null,
),
),
),
)
.toList(),
child: Container(
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: isSelected ? theme.colorScheme.primary : null,
),
),
);
}
return Theme(
data: theme.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: isSelected,
maintainState: true,
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
leading: Icon(
icon,
color: isSelected ? theme.colorScheme.primary : null,
),
title: Text(
title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.clip,
),
children: subItems.map((item) {
final isSubSelected = widget.currentPath == item.pathToCheck;
return Padding(
padding: const EdgeInsets.only(left: 32.0, bottom: 4.0),
child: ListTile(
dense: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
selectedTileColor: theme.colorScheme.primaryContainer.withValues(
alpha: 0.2,
),
selected: isSubSelected,
title: Text(
item.title,
style: TextStyle(
fontWeight: isSubSelected
? FontWeight.bold
: FontWeight.normal,
color: isSubSelected
? theme.colorScheme.primary
: theme.textTheme.bodyMedium?.color,
),
maxLines: 1,
overflow: TextOverflow.clip,
),
onTap: () {
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(item.routeName); // <--- goNamed!
},
),
);
}).toList(),
),
);
}
}
// Struttura dati per le voci dei sottomenu aggiornata
class _SubMenuItem {
final String title;
final String routeName; // Es: Routes.customers
final String pathToCheck; // Es: '/master-data/customers'
_SubMenuItem(this.title, this.routeName, this.pathToCheck);
}

View File

@@ -126,78 +126,89 @@ class AppRouter {
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
// ==========================================
// 1. DASHBOARD
// ==========================================
GoRoute(
path: '/',
name: Routes.home,
builder: (context, state) => const HomeScreen(),
),
// ==========================================
// 2. HUB ANAGRAFICHE E SOTTO-ROTTE
// ==========================================
GoRoute(
path: '/master-data',
name: Routes.masterData,
builder: (context, state) => const MasterDataHubScreen(),
routes: [
GoRoute(
path: 'products', // Diventa /master-data/products
path:
'customers', // Niente slash iniziale per le sottorotte! -> /master-data/customers
name: Routes.customers,
builder: (context, state) => const CustomersListScreen(),
),
GoRoute(
path: 'providers', // -> /master-data/providers
name: Routes.providers,
builder: (context, state) => const ProviderListScreen(),
),
GoRoute(
path: 'products', // -> /master-data/products
name: Routes.products,
builder: (context, state) {
context.read<ProductsCubit>().refreshCubit();
return const ProductsScreen();
},
),
GoRoute(
path: 'company-settings',
path: 'staff', // -> /master-data/staff
name: Routes.staff,
builder: (context, state) => const StaffScreen(),
),
GoRoute(
path:
'stores', // Sistemata l'inversione path/name -> /master-data/stores
name: Routes.stores,
builder: (context, state) => const StoresScreen(),
),
GoRoute(
path: 'company-settings', // -> /master-data/company-settings
name: Routes.companySettings,
builder: (context, state) => BlocProvider(
create: (context) => CompanySettingsCubit(),
child: const CompanySettingsScreen(),
),
),
GoRoute(
path: 'staff',
name: Routes.staff, // Diventa /master-data/staff
builder: (context, state) => const StaffScreen(),
),
GoRoute(
path: Routes.stores,
name: 'stores', // Diventa /master-data/stores
builder: (context, state) => const StoresScreen(),
),
GoRoute(
path: '/providers',
name: Routes.providers,
builder: (context, state) => const ProviderListScreen(),
),
],
),
// ==========================================
// 3. IMPOSTAZIONI
// ==========================================
GoRoute(
path: '/settings',
name: Routes.settings,
builder: (context, state) => const SettingsScreen(),
routes: [
GoRoute(
path: 'themeSettings',
path: 'themeSettings', // -> /settings/themeSettings
name: Routes.themeSettings,
builder: (context, state) => const ThemeSettingsView(),
),
],
),
// ==========================================
// 4. SCHERMATE PRINCIPALI EXTRA NELLA SHELL
// (Accessibili ad es. dalla dashboard, mantengono la sidebar)
// ==========================================
GoRoute(
path: '/operations',
name: Routes.operations,
builder: (context, state) => const OperationListScreen(),
),
GoRoute(
path: '/customers',
name: Routes.customers,
builder: (context, state) =>
const CustomersListScreen(), // O come si chiama il tuo widget della lista!
),
GoRoute(
path: '/tickets',
name: Routes.tickets,