ok, design pulito e gorouter perfezionato (#11)
Reviewed-on: #11 Co-authored-by: Mark M2 Macbook <marco@catelli.it> Co-committed-by: Mark M2 Macbook <marco@catelli.it>
This commit is contained in:
96
lib/core/layout/app_shell.dart
Normal file
96
lib/core/layout/app_shell.dart
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: isDesktop
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
NavigationRail(
|
||||||
|
selectedIndex: currentIndex,
|
||||||
|
onDestinationSelected: (index) =>
|
||||||
|
_onItemTapped(index, context),
|
||||||
|
labelType: NavigationRailLabelType.all,
|
||||||
|
destinations: const [
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(Icons.dashboard_outlined),
|
||||||
|
selectedIcon: Icon(Icons.dashboard),
|
||||||
|
label: Text('Dashboard'),
|
||||||
|
),
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(Icons.folder_special_outlined),
|
||||||
|
selectedIcon: Icon(Icons.folder_special),
|
||||||
|
label: Text('Anagrafiche'),
|
||||||
|
),
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(Icons.settings_outlined),
|
||||||
|
selectedIcon: Icon(Icons.settings),
|
||||||
|
label: Text('Impostazioni'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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: const [
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.dashboard_outlined),
|
||||||
|
selectedIcon: Icon(Icons.dashboard),
|
||||||
|
label: 'Dashboard',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.folder_special_outlined),
|
||||||
|
selectedIcon: Icon(Icons.folder_special),
|
||||||
|
label: 'Anagrafiche',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.settings_outlined),
|
||||||
|
selectedIcon: Icon(Icons.settings),
|
||||||
|
label: 'Impostazioni',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
// Importa il tuo SessionCubit e lo State
|
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/core/data/core_repository.dart';
|
import 'package:flux/core/data/core_repository.dart';
|
||||||
|
import 'package:flux/core/layout/app_shell.dart';
|
||||||
import 'package:flux/core/widgets/set_password_screen.dart';
|
import 'package:flux/core/widgets/set_password_screen.dart';
|
||||||
import 'package:flux/features/customers/ui/customer_mobile_upload_screen.dart';
|
|
||||||
import 'package:flux/features/auth/ui/auth_screen.dart';
|
import 'package:flux/features/auth/ui/auth_screen.dart';
|
||||||
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
|
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
|
||||||
import 'package:flux/features/customers/models/customer_model.dart';
|
import 'package:flux/features/customers/models/customer_model.dart';
|
||||||
import 'package:flux/features/customers/ui/customer_detail_screen.dart';
|
import 'package:flux/features/customers/ui/customer_detail_screen.dart';
|
||||||
|
import 'package:flux/features/customers/ui/customer_mobile_upload_screen.dart';
|
||||||
|
import 'package:flux/features/customers/ui/customers_content.dart';
|
||||||
import 'package:flux/features/home/ui/home_screen.dart';
|
import 'package:flux/features/home/ui/home_screen.dart';
|
||||||
|
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
||||||
import 'package:flux/features/master_data/products/ui/products_screen.dart';
|
import 'package:flux/features/master_data/products/ui/products_screen.dart';
|
||||||
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
|
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
|
||||||
import 'package:flux/features/onboarding/ui/onboarding_screen.dart';
|
import 'package:flux/features/onboarding/ui/onboarding_screen.dart';
|
||||||
@@ -22,57 +23,44 @@ import 'package:flux/features/services/ui/service_form_screen/service_mobile_upl
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
// Nota: Dovrai creare questi placeholder o file per non avere errori di compilazione
|
||||||
|
// import 'package:flux/features/master_data/master_data_hub_screen.dart';
|
||||||
|
// import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
|
||||||
|
// import 'package:flux/features/master_data/store/ui/stores_screen.dart';
|
||||||
|
|
||||||
class AppRouter {
|
class AppRouter {
|
||||||
static GoRouter createRouter(SessionCubit sessionCubit) {
|
static GoRouter createRouter(SessionCubit sessionCubit) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/',
|
initialLocation: '/',
|
||||||
// MAGIA 1: Il router "ascolta" ogni singolo respiro del SessionCubit
|
|
||||||
refreshListenable: GoRouterRefreshStream(sessionCubit.stream),
|
refreshListenable: GoRouterRefreshStream(sessionCubit.stream),
|
||||||
|
|
||||||
// MAGIA 2: Il Buttafuori Supremo
|
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final sessionState = sessionCubit.state;
|
final sessionState = sessionCubit.state;
|
||||||
final isGoingToLogin = state.matchedLocation == '/login';
|
final isGoingToLogin = state.matchedLocation == '/login';
|
||||||
final isGoingToOnboarding = state.matchedLocation == '/onboarding';
|
final isGoingToOnboarding = state.matchedLocation == '/onboarding';
|
||||||
final isGoingToSetPassword = state.matchedLocation == '/set-password';
|
final isGoingToSetPassword = state.matchedLocation == '/set-password';
|
||||||
|
|
||||||
// Caso 1: L'app si sta ancora avviando.
|
if (sessionState.status == SessionStatus.initial) return null;
|
||||||
// Restituiamo null per farlo rimanere sulla SplashScreen del main.dart
|
|
||||||
if (sessionState.status == SessionStatus.initial) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Caso 2: Utente NON loggato.
|
|
||||||
if (sessionState.status == SessionStatus.unauthenticated) {
|
if (sessionState.status == SessionStatus.unauthenticated) {
|
||||||
// Se sta già andando al login, lascialo andare. Altrimenti, forzalo al login.
|
|
||||||
if (isGoingToLogin || isGoingToSetPassword) return null;
|
if (isGoingToLogin || isGoingToSetPassword) return null;
|
||||||
return '/login';
|
return '/login';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caso 3: Utente loggato MA manca un pezzo dell'azienda (Flusso Canalizzatore)
|
|
||||||
if (sessionState.status == SessionStatus.onboardingRequired) {
|
if (sessionState.status == SessionStatus.onboardingRequired) {
|
||||||
// Se sta già andando all'onboarding, ok. Altrimenti forzalo lì.
|
|
||||||
// Non può "scappare" digitando l'URL della dashboard!
|
|
||||||
return isGoingToOnboarding ? null : '/onboarding';
|
return isGoingToOnboarding ? null : '/onboarding';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caso 4: Utente loggato e configurato (Tutto OK!)
|
|
||||||
if (sessionState.status == SessionStatus.authenticated) {
|
if (sessionState.status == SessionStatus.authenticated) {
|
||||||
// Attenzione: un utente appena invitato viene considerato "loggato"
|
if (isGoingToLogin || isGoingToOnboarding) return '/';
|
||||||
// da Supabase appena clicca il link. Quindi se sta andando su /set-password,
|
return null;
|
||||||
// dobbiamo permetterglielo e non rimbalzarlo!
|
|
||||||
if (isGoingToLogin || isGoingToOnboarding) {
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
return null; // Lascia passare per /, /customer, e anche /set-password
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
|
// --- ROTTE DI SERVIZIO (FUORI DALLA SHELL) ---
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/login',
|
path: '/login',
|
||||||
//builder: (context, state) => const LoginScreen(),
|
|
||||||
builder: (context, state) => const AuthScreen(),
|
builder: (context, state) => const AuthScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@@ -88,18 +76,69 @@ class AppRouter {
|
|||||||
),
|
),
|
||||||
child: const OnboardingScreen(),
|
child: const OnboardingScreen(),
|
||||||
),
|
),
|
||||||
// Nota: All'interno di questa schermata useremo il PageView pilotato
|
),
|
||||||
// dall'OnboardingStep. Al router non interessa quale step è attivo,
|
|
||||||
// gli basta sapere che deve stare rinchiuso qui dentro!
|
// --- CORE APP (DENTRO LA SHELL CON NAVIGATION BAR/RAIL) ---
|
||||||
|
ShellRoute(
|
||||||
|
builder: (context, state, child) => AppShell(child: child),
|
||||||
|
routes: [
|
||||||
|
// 1. DASHBOARD
|
||||||
|
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
|
||||||
|
|
||||||
|
// 2. HUB ANAGRAFICHE E SOTTO-ROTTE
|
||||||
|
GoRoute(
|
||||||
|
path: '/master-data',
|
||||||
|
builder: (context, state) => const MasterDataHubScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'products', // Diventa /master-data/products
|
||||||
|
builder: (context, state) => const ProductsScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: 'staff', // Diventa /master-data/staff
|
||||||
builder: (context, state) => const HomeScreen(), // La tua home
|
builder: (context, state) =>
|
||||||
|
const Scaffold(body: Center(child: Text("Lista Staff"))),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'stores', // Diventa /master-data/stores
|
||||||
|
builder: (context, state) =>
|
||||||
|
const Scaffold(body: Center(child: Text("Lista Negozi"))),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'providers', // Diventa /master-data/providers
|
||||||
|
builder: (context, state) => const Scaffold(
|
||||||
|
body: Center(child: Text("Lista Fornitori")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// 3. IMPOSTAZIONI
|
||||||
|
GoRoute(
|
||||||
|
path: '/settings',
|
||||||
|
builder: (context, state) => Scaffold(
|
||||||
|
appBar: AppBar(title: const Text("Impostazioni")),
|
||||||
|
body: Center(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () => context.read<SessionCubit>().signOut(),
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
label: const Text("Esci da FLUX"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
|
||||||
|
GoRoute(
|
||||||
|
path: '/customers',
|
||||||
|
builder: (context, state) =>
|
||||||
|
const CustomersContent(), // O come si chiama il tuo widget della lista!
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/customer/:id',
|
path: '/customer/:id',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
// Recuperiamo l'oggetto customer passato tramite extra
|
|
||||||
final customer = state.extra as CustomerModel;
|
final customer = state.extra as CustomerModel;
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => CustomerFilesBloc(customer.id!),
|
create: (context) => CustomerFilesBloc(customer.id!),
|
||||||
@@ -111,10 +150,7 @@ class AppRouter {
|
|||||||
path: '/customer/:id/upload',
|
path: '/customer/:id/upload',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final customerId = state.pathParameters['id']!;
|
final customerId = state.pathParameters['id']!;
|
||||||
// Recuperiamo il nome dalle query se vogliamo mostrarlo nel titolo,
|
|
||||||
// oppure lo caricherà il bloc.
|
|
||||||
final customerName = state.uri.queryParameters['name'] ?? 'Cliente';
|
final customerName = state.uri.queryParameters['name'] ?? 'Cliente';
|
||||||
|
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => CustomerFilesBloc(customerId),
|
create: (context) => CustomerFilesBloc(customerId),
|
||||||
child: CustomerMobileUploadScreen(
|
child: CustomerMobileUploadScreen(
|
||||||
@@ -124,20 +160,12 @@ class AppRouter {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: '/products',
|
|
||||||
name: 'products',
|
|
||||||
builder: (context, state) => const ProductsScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/service-form',
|
path: '/service-form',
|
||||||
name: 'service-form',
|
name: 'service-form',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
// Recuperiamo l'oggetto se passato tramite 'extra'
|
|
||||||
final existingService = state.extra as ServiceModel?;
|
final existingService = state.extra as ServiceModel?;
|
||||||
// Recuperiamo l'ID se presente nell'URL
|
|
||||||
final serviceId = state.uri.queryParameters['serviceId'];
|
final serviceId = state.uri.queryParameters['serviceId'];
|
||||||
|
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) =>
|
create: (context) =>
|
||||||
ServiceFilesBloc(serviceId: serviceId ?? existingService?.id),
|
ServiceFilesBloc(serviceId: serviceId ?? existingService?.id),
|
||||||
@@ -153,9 +181,7 @@ class AppRouter {
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final serviceId = state.pathParameters['id']!;
|
final serviceId = state.pathParameters['id']!;
|
||||||
final serviceName = state.uri.queryParameters['name'] ?? 'Pratica';
|
final serviceName = state.uri.queryParameters['name'] ?? 'Pratica';
|
||||||
|
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
// Inizializziamo il bloc col serviceId corretto!
|
|
||||||
create: (context) => ServiceFilesBloc(serviceId: serviceId),
|
create: (context) => ServiceFilesBloc(serviceId: serviceId),
|
||||||
child: ServiceMobileUploadScreen(
|
child: ServiceMobileUploadScreen(
|
||||||
serviceId: serviceId,
|
serviceId: serviceId,
|
||||||
@@ -169,8 +195,6 @@ class AppRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Utility fondamentale per GoRouter: trasforma lo Stream del Cubit
|
|
||||||
/// in un Listenable che GoRouter può ascoltare per forzare i redirect.
|
|
||||||
class GoRouterRefreshStream extends ChangeNotifier {
|
class GoRouterRefreshStream extends ChangeNotifier {
|
||||||
GoRouterRefreshStream(Stream<dynamic> stream) {
|
GoRouterRefreshStream(Stream<dynamic> stream) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -178,9 +202,7 @@ class GoRouterRefreshStream extends ChangeNotifier {
|
|||||||
(dynamic _) => notifyListeners(),
|
(dynamic _) => notifyListeners(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
late final StreamSubscription<dynamic> _subscription;
|
late final StreamSubscription<dynamic> _subscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_subscription.cancel();
|
_subscription.cancel();
|
||||||
|
|||||||
@@ -2,102 +2,98 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/core/theme/theme.dart';
|
import 'package:flux/core/theme/theme.dart';
|
||||||
import 'package:flux/features/auth/bloc/auth_cubit.dart';
|
import 'package:flux/features/home/ui/quick_actions_widget.dart';
|
||||||
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||||
import 'package:flux/features/services/blocs/services_cubit.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:flux/features/services/ui/services_screen.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'dashboard_content.dart';
|
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
|
||||||
State<HomeScreen> createState() => _HomeScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
|
||||||
int _selectedIndex = 0;
|
|
||||||
bool _extendRailway = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
context.read<ServicesCubit>().loadServices();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<SessionCubit, SessionState>(
|
final theme = Theme.of(context);
|
||||||
builder: (context, state) {
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final bool isLargeScreen = constraints.maxWidth > 900;
|
|
||||||
final bool veryLargeScreen = constraints.maxWidth > 1200;
|
|
||||||
final bool isMenuExtended = veryLargeScreen ? true : _extendRailway;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
// --- APPBAR (Solo Mobile) ---
|
backgroundColor: theme.colorScheme.surface,
|
||||||
appBar: isLargeScreen
|
body: SafeArea(
|
||||||
? null
|
|
||||||
: AppBar(
|
|
||||||
title: const Text(
|
|
||||||
'FLUX Gestionale',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
elevation: 0,
|
|
||||||
actions: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 16.0),
|
|
||||||
child: _buildUserMenu(context, isExtended: false),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Row(
|
|
||||||
children: [
|
|
||||||
// --- SIDEBAR (Desktop) ---
|
|
||||||
if (isLargeScreen) _buildDesktopSidebar(isMenuExtended),
|
|
||||||
|
|
||||||
// --- CONTENUTO DINAMICO ---
|
|
||||||
Expanded(
|
|
||||||
child: _buildPageContent(_selectedIndex, isLargeScreen),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// --- BOTTOM BAR (Solo Mobile) ---
|
|
||||||
bottomNavigationBar: isLargeScreen
|
|
||||||
? null
|
|
||||||
: _buildBottomNavigationBar(_selectedIndex),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// COMPONENTI UI
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
// Costruisce l'intera colonna laterale (Rail + Menu Utente in fondo)
|
|
||||||
Widget _buildDesktopSidebar(bool isExtended) {
|
|
||||||
return MouseRegion(
|
|
||||||
// Spostiamo qui la logica dell'hover!
|
|
||||||
onEnter: (_) => setState(() => _extendRailway = true),
|
|
||||||
onExit: (_) => setState(() => _extendRailway = false),
|
|
||||||
child: Container(
|
|
||||||
color: context.background, // Mantiene lo stesso colore della Rail
|
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// ==========================================
|
||||||
|
// 1. HEADER FISSO (Non scrolla mai)
|
||||||
|
// ==========================================
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
// Un leggero colore di sfondo aiuta a staccare l'header quando il contenuto ci passa sotto
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
child: _buildHeader(context, theme),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. CORPO DELLA DASHBOARD (Scrollabile)
|
||||||
|
// ==========================================
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildNavigationRail(isExtended), // Ora la Rail è "nuda"
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
// --- QUICK ACTIONS: AZIONI RAPIDE ---
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||||
|
child: _buildQuickActions(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
|
||||||
|
// --- I WIDGET DELLA DASHBOARD (Responsive Grid) ---
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 500,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
childAspectRatio: 1.3,
|
||||||
|
),
|
||||||
|
delegate: SliverChildListDelegate([
|
||||||
|
_buildDashboardWidget(
|
||||||
|
title: 'Contratti in Scadenza',
|
||||||
|
icon: Icons.assignment_late_outlined,
|
||||||
|
color: Colors.orange,
|
||||||
|
context: context,
|
||||||
|
),
|
||||||
|
_buildDashboardWidget(
|
||||||
|
title: 'Sticky Notes',
|
||||||
|
icon: Icons.sticky_note_2_outlined,
|
||||||
|
color: Colors.yellow.shade700,
|
||||||
|
context: context,
|
||||||
|
),
|
||||||
|
_buildDashboardWidget(
|
||||||
|
title: 'I miei Task',
|
||||||
|
icon: Icons.check_box_outlined,
|
||||||
|
color: Colors.green,
|
||||||
|
context: context,
|
||||||
|
),
|
||||||
|
_buildDashboardWidget(
|
||||||
|
title: 'Ultimi Servizi',
|
||||||
|
icon: Icons.design_services_outlined,
|
||||||
|
color: Colors.blue,
|
||||||
|
context: context,
|
||||||
|
),
|
||||||
|
_buildDashboardWidget(
|
||||||
|
title: 'Ultime Assistenze',
|
||||||
|
icon: Icons.support_agent_outlined,
|
||||||
|
color: Colors.purple,
|
||||||
|
context: context,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Spazio finale per non far attaccare l'ultima card al fondo
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 40)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
// --- AVATAR E MENU IN FONDO ALLA SIDEBAR ---
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 24.0, top: 8.0),
|
|
||||||
child: _buildUserMenu(context, isExtended: isExtended),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -105,220 +101,285 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNavigationRail(bool isExtended) {
|
// ==========================================
|
||||||
return NavigationRail(
|
// WIDGET BUILDERS
|
||||||
extended: isExtended,
|
// ==========================================
|
||||||
selectedIndex: _selectedIndex,
|
|
||||||
onDestinationSelected: (index) => setState(() => _selectedIndex = index),
|
Widget _buildHeader(BuildContext context, ThemeData theme) {
|
||||||
backgroundColor: Colors
|
final user = context.watch<SessionCubit>().state.currentStaffMember;
|
||||||
.transparent, // Impostato trasparente per prendere il colore del Container padre
|
final currentStore = context.watch<SessionCubit>().state.currentStore;
|
||||||
indicatorColor: context.accent.withValues(alpha: 0.2),
|
|
||||||
leading: _buildRailHeader(isExtended),
|
return Row(
|
||||||
selectedIconTheme: IconThemeData(color: context.accent, size: 28),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
unselectedIconTheme: IconThemeData(
|
children: [
|
||||||
color: context.secondaryText,
|
Expanded(
|
||||||
size: 24,
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
selectedLabelTextStyle: TextStyle(
|
children: [
|
||||||
color: context.accent,
|
Text(
|
||||||
|
"Bentornato, ${user!.name}! 👋",
|
||||||
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
color: context.primaryText, // Uso dell'estensione!
|
||||||
),
|
),
|
||||||
unselectedLabelTextStyle: TextStyle(color: context.secondaryText),
|
|
||||||
destinations: const [
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.dashboard_outlined),
|
|
||||||
selectedIcon: Icon(Icons.dashboard),
|
|
||||||
label: Text('Dashboard'),
|
|
||||||
),
|
),
|
||||||
NavigationRailDestination(
|
const SizedBox(height: 8),
|
||||||
icon: Icon(Icons.receipt_long_outlined),
|
|
||||||
selectedIcon: Icon(Icons.receipt_long),
|
|
||||||
label: Text('Servizi'),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.folder_shared_outlined),
|
|
||||||
selectedIcon: Icon(Icons.folder_shared),
|
|
||||||
label: Text('Anagrafiche'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- MENU UTENTE (Il "Pro" Avatar) ---
|
InkWell(
|
||||||
Widget _buildUserMenu(BuildContext context, {required bool isExtended}) {
|
onTap: () => _showStoreSelector(context, theme),
|
||||||
// Il PopupMenuButton gestisce da solo l'apertura a tendina
|
borderRadius: BorderRadius.circular(8),
|
||||||
return PopupMenuButton<String>(
|
child: Container(
|
||||||
offset: const Offset(
|
padding: const EdgeInsets.symmetric(
|
||||||
0,
|
horizontal: 12,
|
||||||
-120,
|
vertical: 6,
|
||||||
), // Apre il menu verso l'alto su desktop se necessario
|
|
||||||
tooltip: 'Profilo e Impostazioni',
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
onSelected: (value) {
|
|
||||||
if (value == 'logout') {
|
|
||||||
_showLogoutDialog(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (BuildContext context) => [
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: 'profile',
|
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(Icons.person_outline),
|
|
||||||
title: Text('Il mio Profilo'),
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const PopupMenuDivider(),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: 'logout',
|
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(Icons.logout, color: Colors.red),
|
|
||||||
title: Text(
|
|
||||||
'Esci',
|
|
||||||
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// L'aspetto del pulsante (Icona tonda o Icona + Nome se esteso)
|
|
||||||
child: isExtended
|
|
||||||
? Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(24),
|
color: context.primary.withValues(
|
||||||
color: context.accent.withValues(alpha: 0.1),
|
alpha: 0.08,
|
||||||
|
), // Sfondo delicato
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: context.primary.withValues(
|
||||||
|
alpha: 0.2,
|
||||||
|
), // Bordino netto
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
Icon(Icons.storefront, size: 16, color: context.primary),
|
||||||
radius: 16,
|
const SizedBox(width: 8),
|
||||||
backgroundColor: context.accent,
|
Text(
|
||||||
child: const Icon(
|
currentStore?.nome ?? "Nessun negozio",
|
||||||
Icons.person,
|
style: TextStyle(
|
||||||
color: Colors.white,
|
fontWeight: FontWeight.w600,
|
||||||
size: 20,
|
color: context.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(Icons.arrow_drop_down, color: context.primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 24,
|
||||||
|
// Usiamo il Turchese (accent) in trasparenza per l'avatar
|
||||||
|
backgroundColor: context.accent.withValues(alpha: 0.15),
|
||||||
|
child: Icon(Icons.person, color: context.accent, size: 26),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuickActions(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
QuickActionButton(
|
||||||
|
icon: Icons.add,
|
||||||
|
label: "Servizio",
|
||||||
|
color: Colors.blue,
|
||||||
|
onTap: () {
|
||||||
|
// Entriamo nel form! Nessun parametro extra = Nuovo Servizio
|
||||||
|
context.push('/service-form');
|
||||||
|
},
|
||||||
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
QuickActionButton(
|
||||||
GetIt.I.get<SessionCubit>().state.company?.ragioneSociale ??
|
icon: Icons.handyman,
|
||||||
"Utente",
|
label: "Assistenza",
|
||||||
style: TextStyle(
|
color: Colors.redAccent,
|
||||||
fontWeight: FontWeight.bold,
|
onTap: () {
|
||||||
color: context.accent,
|
// TODO: Quando avrai la rotta per la nuova assistenza
|
||||||
|
// context.push('/assistance-form');
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
QuickActionButton(
|
||||||
|
icon: Icons.note_add,
|
||||||
|
label: "Nota",
|
||||||
|
color: Colors.amber,
|
||||||
|
onTap: () {
|
||||||
|
// TODO: Quando faremo il modale/pagina delle note
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
QuickActionButton(
|
||||||
|
icon: Icons.task_alt,
|
||||||
|
label: "Task",
|
||||||
|
color: Colors.teal,
|
||||||
|
onTap: () {
|
||||||
|
// TODO: Quando faremo i task
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: CircleAvatar(
|
|
||||||
radius: 18,
|
|
||||||
backgroundColor: context.accent,
|
|
||||||
child: const Icon(Icons.person, color: Colors.white),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DIALOG DI CONFERMA LOGOUT ---
|
Widget _buildDashboardWidget({
|
||||||
void _showLogoutDialog(BuildContext context) {
|
required BuildContext context,
|
||||||
showDialog(
|
required String title,
|
||||||
|
required IconData icon,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: color, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: context.primaryText,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.more_vert,
|
||||||
|
size: 20,
|
||||||
|
color: context.secondaryText,
|
||||||
|
),
|
||||||
|
onPressed: () {},
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
"(Coming Soon)",
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.secondaryText.withValues(alpha: 0.7),
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showStoreSelector(BuildContext context, ThemeData theme) {
|
||||||
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
shape: const RoundedRectangleBorder(
|
||||||
title: const Row(
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
builder: (context) {
|
||||||
|
// Leggiamo la lista dei negozi dal Cubit dedicato (se ce l'hai lì)
|
||||||
|
// Oppure adatta il blocco se li salvi altrove!
|
||||||
|
final staffState = context.watch<StaffCubit>().state;
|
||||||
|
final currentStoreId = context
|
||||||
|
.read<SessionCubit>()
|
||||||
|
.state
|
||||||
|
.currentStore
|
||||||
|
?.id;
|
||||||
|
final currentStaffId = context
|
||||||
|
.read<SessionCubit>()
|
||||||
|
.state
|
||||||
|
.currentStaffMember
|
||||||
|
?.id;
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.logout, color: Colors.red),
|
Padding(
|
||||||
SizedBox(width: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||||
Text("Chiudi sessione"),
|
child: Text(
|
||||||
],
|
"Seleziona Negozio",
|
||||||
),
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
content: const Text("Sei sicuro di voler uscire dal gestionale?"),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(dialogContext),
|
|
||||||
child: const Text("Annulla"),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(dialogContext); // Chiude la Dialog
|
|
||||||
context.read<AuthCubit>().requestLogout(); // Esegue il logout
|
|
||||||
},
|
|
||||||
child: const Text("Esci"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... mantieni gli altri tuoi metodi intatti (_buildRailHeader, _buildPageContent, _buildBottomNavigationBar)
|
|
||||||
|
|
||||||
Widget _buildRailHeader(bool veryLargeScreen) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: veryLargeScreen
|
|
||||||
? null
|
|
||||||
: () => setState(() => _extendRailway = !_extendRailway),
|
|
||||||
child: _extendRailway
|
|
||||||
? Text(
|
|
||||||
'FLUX',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 24,
|
|
||||||
color: context.accent,
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (staffState.status == StaffStatus.loading)
|
||||||
|
const Center(child: CircularProgressIndicator())
|
||||||
|
else if (staffState.storesByStaff[currentStaffId] == null)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(24.0),
|
||||||
|
child: Text("Nessun negozio disponibile."),
|
||||||
)
|
)
|
||||||
: Icon(Icons.bolt, color: context.accent, size: 32),
|
else
|
||||||
|
...staffState.storesByStaff[currentStaffId]!.map((store) {
|
||||||
|
final isSelected = store.id == currentStoreId;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24.0,
|
||||||
),
|
),
|
||||||
|
leading: Icon(
|
||||||
|
Icons.storefront,
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.iconTheme.color,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
store.nome,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isSelected ? theme.colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: isSelected
|
||||||
|
? Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
// Cambiamo il negozio nel SessionCubit!
|
||||||
|
context.read<SessionCubit>().changeStore(store);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}),
|
||||||
|
|
||||||
Widget _buildBottomNavigationBar(int selectedIndex) {
|
|
||||||
return BottomNavigationBar(
|
|
||||||
currentIndex: selectedIndex,
|
|
||||||
onTap: (index) => setState(() => _selectedIndex = index),
|
|
||||||
selectedItemColor: context.accent,
|
|
||||||
unselectedItemColor: context.secondaryText,
|
|
||||||
items: const [
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.dashboard),
|
|
||||||
label: 'Dashboard',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.receipt_long),
|
|
||||||
label: 'Servizi',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.folder_shared),
|
|
||||||
label: 'Anagrafiche',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPageContent(int index, bool isLargeScreen) {
|
|
||||||
return IndexedStack(
|
|
||||||
index: index,
|
|
||||||
children: [
|
|
||||||
DashboardContent(
|
|
||||||
isLargeScreen: isLargeScreen,
|
|
||||||
onTabRequested: (idx) => setState(() => _selectedIndex = 2),
|
|
||||||
),
|
),
|
||||||
const ServicesScreen(),
|
),
|
||||||
MasterDataHubContent(
|
|
||||||
onOpenPage: (widget) {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => widget),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
lib/features/home/ui/quick_actions_widget.dart
Normal file
45
lib/features/home/ui/quick_actions_widget.dart
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// --- WIDGET MINORE DI SUPPORTO ---
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class QuickActionButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const QuickActionButton({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||||
|
color: color.withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, color: color),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +1,51 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flux/core/theme/theme.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:flux/features/customers/ui/customers_content.dart';
|
// Mantieni i tuoi import per il tema se usi le estensioni (es. context.accent)
|
||||||
import 'package:flux/features/master_data/products/ui/products_screen.dart';
|
// import 'package:flux/core/theme/theme.dart';
|
||||||
import 'package:flux/features/master_data/providers/ui/providers_master_data_screen.dart';
|
|
||||||
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
|
|
||||||
import 'package:flux/features/master_data/store/ui/stores_screen.dart';
|
|
||||||
|
|
||||||
class MasterDataHubContent extends StatelessWidget {
|
class MasterDataHubScreen extends StatelessWidget {
|
||||||
final Function(Widget) onOpenPage;
|
const MasterDataHubScreen({super.key});
|
||||||
|
|
||||||
const MasterDataHubContent({super.key, required this.onOpenPage});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final theme = Theme.of(context);
|
||||||
padding: const EdgeInsets.all(23.0),
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Anagrafiche",
|
"Anagrafiche",
|
||||||
style: TextStyle(
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: context.accent,
|
letterSpacing: -0.5,
|
||||||
|
// Se preferisci la tua estensione, usa: color: context.accent,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
"Gestisci i dati fondamentali del tuo business",
|
"Gestisci i dati fondamentali del tuo business",
|
||||||
style: TextStyle(color: context.secondaryText),
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
// Se preferisci: color: context.secondaryText,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GridView(
|
child: GridView(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
// MAGIA RESPONSIVA: invece di contare le colonne, diciamo "Ogni card
|
||||||
crossAxisCount: MediaQuery.of(context).size.width > 600 ? 3 : 2,
|
// occupa massimo 350px". Su PC ne metterà 4, su Tablet 2, su Telefono 1 o 2.
|
||||||
mainAxisSpacing: 14,
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
crossAxisSpacing: 14,
|
maxCrossAxisExtent: 350,
|
||||||
// LA MAGIA: Fissiamo l'altezza della card a 200 pixel
|
mainAxisSpacing: 16,
|
||||||
// indipendentemente da quanto sia stretta la colonna!
|
crossAxisSpacing: 16,
|
||||||
mainAxisExtent: 200,
|
mainAxisExtent: 200, // Altezza fissa per impedire overflow
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
_buildHubCard(
|
_buildHubCard(
|
||||||
@@ -50,7 +54,8 @@ class MasterDataHubContent extends StatelessWidget {
|
|||||||
subtitle: 'Anagrafica di Marche e Modelli',
|
subtitle: 'Anagrafica di Marche e Modelli',
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
color: Colors.blue,
|
color: Colors.blue,
|
||||||
onTap: () => onOpenPage(const ProductsScreen()),
|
// Navighiamo dentro la Shell (la BottomBar rimane!)
|
||||||
|
onTap: () => context.go('/master-data/products'),
|
||||||
),
|
),
|
||||||
_buildHubCard(
|
_buildHubCard(
|
||||||
context,
|
context,
|
||||||
@@ -58,15 +63,17 @@ class MasterDataHubContent extends StatelessWidget {
|
|||||||
subtitle: 'Anagrafica dei clienti del tuo business',
|
subtitle: 'Anagrafica dei clienti del tuo business',
|
||||||
icon: Icons.people_outlined,
|
icon: Icons.people_outlined,
|
||||||
color: Colors.orange,
|
color: Colors.orange,
|
||||||
onTap: () => onOpenPage(const CustomersContent()),
|
// Usiamo .push() perché avevamo detto che i clienti
|
||||||
|
// stanno FUORI dalla Shell (niente BottomBar)
|
||||||
|
onTap: () => context.push('/customers'),
|
||||||
),
|
),
|
||||||
_buildHubCard(
|
_buildHubCard(
|
||||||
context,
|
context,
|
||||||
title: 'Addetti',
|
title: 'Addetti',
|
||||||
subtitle: 'Anagrafica del personale e dei collaboratori',
|
subtitle: 'Anagrafica del personale e collaboratori',
|
||||||
icon: Icons.badge_outlined,
|
icon: Icons.badge_outlined,
|
||||||
color: Colors.teal,
|
color: Colors.teal,
|
||||||
onTap: () => onOpenPage(const StaffScreen()),
|
onTap: () => context.go('/master-data/staff'),
|
||||||
),
|
),
|
||||||
_buildHubCard(
|
_buildHubCard(
|
||||||
context,
|
context,
|
||||||
@@ -74,32 +81,25 @@ class MasterDataHubContent extends StatelessWidget {
|
|||||||
subtitle: 'Anagrafica punti vendita della tua azienda',
|
subtitle: 'Anagrafica punti vendita della tua azienda',
|
||||||
icon: Icons.storefront_outlined,
|
icon: Icons.storefront_outlined,
|
||||||
color: Colors.purple,
|
color: Colors.purple,
|
||||||
onTap: () => onOpenPage(const StoresScreen()),
|
onTap: () => context.go('/master-data/stores'),
|
||||||
),
|
),
|
||||||
_buildHubCard(
|
_buildHubCard(
|
||||||
context,
|
context,
|
||||||
title:
|
title: 'Provider',
|
||||||
'Provider', // Accorciato per non andare a capo male su mobile
|
|
||||||
subtitle: 'Anagrafica mandati e servizi',
|
subtitle: 'Anagrafica mandati e servizi',
|
||||||
icon: Icons.handshake_rounded,
|
icon: Icons.handshake_rounded,
|
||||||
color: Colors.indigo,
|
color: Colors.indigo,
|
||||||
onTap: () {
|
onTap: () => context.go('/master-data/providers'),
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const ProvidersMasterDataScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHubCard(
|
Widget _buildHubCard(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
@@ -109,14 +109,19 @@ Widget _buildHubCard(
|
|||||||
required Color color,
|
required Color color,
|
||||||
required VoidCallback onTap,
|
required VoidCallback onTap,
|
||||||
}) {
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
elevation: 2,
|
elevation: 0, // Zero elevation, design più flat e moderno
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
// Stesso bordino elegante della Dashboard
|
||||||
|
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
// Ridotto da 22 a 16 per dare più respiro orizzontale su mobile
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -125,26 +130,31 @@ Widget _buildHubCard(
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.1),
|
color: color.withValues(alpha: 0.1), // Niente withOpacity! ❤️
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 40, color: color),
|
child: Icon(icon, size: 36, color: color),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12), // Leggermente ridotto
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4), // Leggermente ridotto
|
const SizedBox(height: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
// Impedisce matematicamente l'overflow verticale
|
|
||||||
child: Text(
|
child: Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@@ -155,3 +165,4 @@ Widget _buildHubCard(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user