refined responsive ui for dashboard and start customer work

This commit is contained in:
2026-04-10 10:47:56 +02:00
parent e06f7329f0
commit 8d6e8647b1
13 changed files with 543 additions and 503 deletions

View File

@@ -0,0 +1,82 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart';
part 'customer_events.dart';
part 'customer_state.dart';
class CustomerBloc extends Bloc<CustomerEvent, CustomerState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
CustomerBloc() : super(const CustomerState()) {
on<LoadCustomersRequested>(_onLoadCustomers);
on<CreateCustomerRequested>(_onCreateCustomer);
on<SearchCustomersRequested>(_onSearchCustomers);
}
Future<void> _onLoadCustomers(
LoadCustomersRequested event,
Emitter<CustomerState> emit,
) async {
emit(state.copyWith(status: CustomerStatus.loading));
try {
final customers = await _repository.getCustomers(event.companyId);
emit(
state.copyWith(status: CustomerStatus.success, customers: customers),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onCreateCustomer(
CreateCustomerRequested event,
Emitter<CustomerState> emit,
) async {
emit(state.copyWith(status: CustomerStatus.loading));
try {
final newCustomer = await _repository.createCustomer(event.customer);
// Aggiorniamo la lista locale aggiungendo il nuovo cliente in cima
final updatedList = List<CustomerModel>.from(state.customers)
..insert(0, newCustomer);
emit(
state.copyWith(
status: CustomerStatus.success,
customers: updatedList,
lastCreatedCustomer:
newCustomer, // Lo passiamo per le Dialog "al volo"
),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onSearchCustomers(
SearchCustomersRequested event,
Emitter<CustomerState> emit,
) async {
// Non mettiamo loading per evitare flickering durante la digitazione
try {
final results = await _repository.searchCustomers(
event.companyId,
event.query,
);
emit(state.copyWith(status: CustomerStatus.success, customers: results));
} catch (_) {}
}
}

View File

@@ -0,0 +1,26 @@
part of 'customer_bloc.dart';
abstract class CustomerEvent extends Equatable {
const CustomerEvent();
@override
List<Object?> get props => [];
}
// Carica tutti i clienti dell'azienda
class LoadCustomersRequested extends CustomerEvent {
final String companyId;
const LoadCustomersRequested(this.companyId);
}
// Crea un cliente (usato sia dalla lista che dalla Dialog operazioni)
class CreateCustomerRequested extends CustomerEvent {
final CustomerModel customer;
const CreateCustomerRequested(this.customer);
}
// Ricerca in tempo reale
class SearchCustomersRequested extends CustomerEvent {
final String companyId;
final String query;
const SearchCustomersRequested(this.companyId, this.query);
}

View File

@@ -0,0 +1,40 @@
part of 'customer_bloc.dart';
enum CustomerStatus { initial, loading, success, failure }
class CustomerState extends Equatable {
final CustomerStatus status;
final List<CustomerModel> customers; // Per la lista generale
final CustomerModel?
lastCreatedCustomer; // <--- Fondamentale per la Dialog "al volo"
final String? errorMessage;
const CustomerState({
this.status = CustomerStatus.initial,
this.customers = const [],
this.lastCreatedCustomer,
this.errorMessage,
});
CustomerState copyWith({
CustomerStatus? status,
List<CustomerModel>? customers,
CustomerModel? lastCreatedCustomer,
String? errorMessage,
}) {
return CustomerState(
status: status ?? this.status,
customers: customers ?? this.customers,
lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
status,
customers,
lastCreatedCustomer,
errorMessage,
];
}

View File

@@ -0,0 +1,56 @@
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/customer_model.dart';
class CustomerRepository {
final SupabaseClient _client = GetIt.I<SupabaseClient>();
// Crea un nuovo cliente
Future<CustomerModel> createCustomer(CustomerModel customer) async {
try {
final response = await _client
.from('customer')
.insert(customer.toJson())
.select()
.single();
return CustomerModel.fromJson(response);
} catch (e) {
throw 'Errore durante la creazione del cliente: $e';
}
}
// Recupera tutti i clienti dell'azienda
Future<List<CustomerModel>> getCustomers(String companyId) async {
try {
final response = await _client
.from('customer')
.select()
.eq('company_id', companyId)
.eq('is_active', true)
.order('nome');
return (response as List).map((c) => CustomerModel.fromJson(c)).toList();
} catch (e) {
throw 'Errore nel recupero clienti';
}
}
// Ricerca clienti per nome o telefono (fondamentale per la UX)
Future<List<CustomerModel>> searchCustomers(
String companyId,
String query,
) async {
try {
final response = await _client
.from('customer')
.select()
.eq('company_id', companyId)
.or('nome.ilike.%$query%,telefono.ilike.%$query%')
.limit(10);
return (response as List).map((c) => CustomerModel.fromJson(c)).toList();
} catch (e) {
return [];
}
}
}

View File

@@ -0,0 +1,101 @@
import 'package:equatable/equatable.dart';
class CustomerModel extends Equatable {
final BigInt? id; // Bigint in SQL
final DateTime? createdAt;
final String nome;
final String telefono;
final String email;
final String note;
final DateTime? dataUltimoContatto;
final bool nonDisturbare;
final String companyId; // UUID
final bool isActive;
const CustomerModel({
this.id,
this.createdAt,
required this.nome,
required this.telefono,
required this.email,
required this.note,
this.dataUltimoContatto,
this.nonDisturbare = false,
required this.companyId,
this.isActive = true,
});
@override
List<Object?> get props => [
id,
createdAt,
nome,
telefono,
email,
note,
dataUltimoContatto,
nonDisturbare,
companyId,
isActive,
];
CustomerModel copyWith({
BigInt? id,
DateTime? createdAt,
String? nome,
String? telefono,
String? email,
String? note,
DateTime? dataUltimoContatto,
bool? nonDisturbare,
String? companyId,
bool? isActive,
}) {
return CustomerModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
nome: nome ?? this.nome,
telefono: telefono ?? this.telefono,
email: email ?? this.email,
note: note ?? this.note,
dataUltimoContatto: dataUltimoContatto ?? this.dataUltimoContatto,
nonDisturbare: nonDisturbare ?? this.nonDisturbare,
companyId: companyId ?? this.companyId,
isActive: isActive ?? this.isActive,
);
}
factory CustomerModel.fromJson(Map<String, dynamic> json) {
return CustomerModel(
id: json['id'],
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'])
: null,
nome: json['nome'],
telefono: json['telefono'],
email: json['email'],
note: json['note'] ?? '',
dataUltimoContatto: json['data_ultimo_contatto'] != null
? DateTime.parse(json['data_ultimo_contatto'])
: null,
nonDisturbare: json['non_disturbare'] ?? false,
companyId: json['company_id'],
isActive: json['is_active'] ?? true,
);
}
Map<String, dynamic> toJson() {
return {
if (id != null) 'id': id,
'nome': nome,
'telefono': telefono,
'email': email,
'note': note,
if (dataUltimoContatto != null)
'data_ultimo_contatto': dataUltimoContatto!.toIso8601String(),
'non_disturbare': nonDisturbare,
'company_id': companyId,
'is_active': isActive,
};
}
}

View File

@@ -1,242 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/core/theme/theme.dart';
class DashboardContent extends StatelessWidget {
final bool? isLargeScreen;
const DashboardContent({super.key, this.isLargeScreen});
@override
Widget build(BuildContext context) {
// Ascoltiamo il SessionBloc per avere i dati in tempo reale
return BlocBuilder<SessionBloc, SessionState>(
builder: (context, state) {
final store = state.selectedStore;
final company = state.company;
return Scaffold(
body: CustomScrollView(
slivers: [
// Un'AppBar elegante che si rimpicciolisce
SliverAppBar(
expandedHeight: 120.0,
floating: false,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(
store?.nome ?? 'Flux Dashboard',
style: TextStyle(color: context.primaryText),
),
background: Container(color: context.background),
),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => Navigator.pushNamed(context, '/settings'),
),
],
),
SliverToBoxAdapter(
child: Center(
child: Container(
constraints: const BoxConstraints(
maxWidth: 1200,
), // Larghezza massima "confortevole"
padding: const EdgeInsets.all(24.0),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWelcomeHeader(context, company?.ragioneSociale),
const SizedBox(height: 30),
_SectionTitle(title: 'AZIONI RAPIDE'),
const SizedBox(height: 16),
_buildGridActions(context),
const SizedBox(height: 32),
_SectionTitle(title: 'STATO NEGOZIO'),
const SizedBox(height: 16),
_buildInfoCard(context, store),
],
),
),
),
),
),
],
),
);
},
);
}
Widget _buildWelcomeHeader(BuildContext context, String? companyName) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bentornato,',
style: TextStyle(color: context.secondaryText, fontSize: 16),
),
Text(
companyName ?? 'La tua Azienda',
style: TextStyle(
color: context.primaryText,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
);
}
Widget _buildGridActions(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Calcoliamo il numero di colonne in base alla larghezza
// Sotto i 600px (mobile): 2 colonne
// Tra 600 e 1000px (tablet): 3 o 4 colonne
// Sopra i 1000px (desktop): 6 colonne
int crossAxisCount = 2;
if (constraints.maxWidth > 1000) {
crossAxisCount = 6;
} else if (constraints.maxWidth > 600) {
crossAxisCount = 4;
}
return GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: crossAxisCount,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
// Su desktop rendiamo i tasti un po' più squadrati (1.0)
// Su mobile manteniamo il rettangolo (1.5)
childAspectRatio: constraints.maxWidth > 600 ? 1.2 : 1.5,
children: [
_ActionCard(
label: 'Nuova Op',
icon: Icons.add_task_rounded,
color: context.accent,
onTap: () {},
),
_ActionCard(
label: 'Clienti',
icon: Icons.people_alt_rounded,
color: Colors.orange,
onTap: () {},
),
_ActionCard(
label: 'Campagne',
icon: Icons.campaign_rounded,
color: Colors.purple,
onTap: () {},
),
_ActionCard(
label: 'Report',
icon: Icons.analytics_rounded,
color: Colors.teal,
onTap: () {},
),
// Se siamo su desktop, possiamo aggiungere altri slot senza affollare
if (constraints.maxWidth > 600) ...[
_ActionCard(
label: 'Impostazioni',
icon: Icons.settings,
color: Colors.grey,
onTap: () {},
),
_ActionCard(
label: 'Supporto',
icon: Icons.help_outline,
color: Colors.blueGrey,
onTap: () {},
),
],
],
);
},
);
}
Widget _buildInfoCard(BuildContext context, dynamic store) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: context.accent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Icon(Icons.location_on_rounded, color: context.accent),
const SizedBox(width: 12),
Expanded(
child: Text(
'${store?.indirizzo}, ${store?.comune}',
style: TextStyle(color: context.primaryText),
),
),
],
),
);
}
}
class _ActionCard extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final VoidCallback onTap;
const _ActionCard({
required this.label,
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: BoxDecoration(
color: context.background,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: context.accent.withValues(alpha: 0.1)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 32),
const SizedBox(height: 8),
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) {
return Text(
title,
style: TextStyle(
color: context.secondaryText,
fontWeight: FontWeight.bold,
fontSize: 12,
letterSpacing: 1.1,
),
);
}
}

View File

@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/core/theme/theme.dart';
class DashboardContent extends StatelessWidget {
final bool isLargeScreen;
const DashboardContent({super.key, this.isLargeScreen = false});
@override
Widget build(BuildContext context) {
return BlocBuilder<SessionBloc, SessionState>(
builder: (context, state) {
final store = state.selectedStore;
final company = state.company;
return Scaffold(
backgroundColor: context.background,
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 100.0,
floating: false,
pinned: true,
elevation: 0,
backgroundColor: context.background,
flexibleSpace: FlexibleSpaceBar(
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
store?.nome ?? 'Dashboard',
style: TextStyle(
color: context.primaryText,
fontWeight: FontWeight.bold,
),
),
),
),
SliverToBoxAdapter(
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 1200),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWelcome(context, company?.ragioneSociale),
const SizedBox(height: 32),
const _SectionTitle(title: 'AZIONI RAPIDE'),
const SizedBox(height: 16),
_buildAdaptiveGrid(context),
const SizedBox(height: 40),
const _SectionTitle(title: 'INFO PUNTO VENDITA'),
const SizedBox(height: 16),
_buildStoreCard(context, store),
],
),
),
),
),
],
),
);
},
);
}
Widget _buildWelcome(BuildContext context, String? name) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Benvenuto in',
style: TextStyle(color: context.secondaryText, fontSize: 16),
),
Text(
name ?? 'Azienda',
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
),
],
);
}
Widget _buildAdaptiveGrid(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Logica Colonne: Mobile 2, Tablet 3, Desktop 4+
int crossAxisCount = 2;
if (constraints.maxWidth > 1000) {
crossAxisCount = 5;
} else if (constraints.maxWidth > 700) {
crossAxisCount = 3;
}
return GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: crossAxisCount,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: isLargeScreen ? 1.3 : 1.5,
children: [
_ActionCard(
label: 'Nuova Op',
icon: Icons.add_task,
color: context.accent,
onTap: () {},
),
_ActionCard(
label: 'Clienti',
icon: Icons.people,
color: Colors.orange,
onTap: () {},
),
_ActionCard(
label: 'Campagne',
icon: Icons.campaign,
color: Colors.purple,
onTap: () {},
),
_ActionCard(
label: 'Report',
icon: Icons.analytics,
color: Colors.teal,
onTap: () {},
),
],
);
},
);
}
Widget _buildStoreCard(BuildContext context, dynamic store) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: context.accent.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: context.accent.withValues(alpha: 0.1)),
),
child: Row(
children: [
Icon(Icons.location_on, color: context.accent),
const SizedBox(width: 16),
Text('${store?.indirizzo}, ${store?.comune} (${store?.provincia})'),
],
),
);
}
}
// Widget di supporto rimasti invariati (ActionCard e SectionTitle)
class _ActionCard extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final VoidCallback onTap;
const _ActionCard({
required this.label,
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
// CAMBIA QUI: da Border.all a BorderSide
side: BorderSide(
color: context.accent.withValues(alpha: 0.1),
width: 1,
),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 32),
const SizedBox(height: 8),
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) => Text(
title,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
fontSize: 12,
letterSpacing: 1.2,
),
);
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/home/ui/dashboard_content.dart';
import 'dashboard_content.dart'; // Importiamo il contenuto della dashboard
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -20,25 +20,28 @@ class _HomeScreenState extends State<HomeScreen> {
builder: (context, state) {
return LayoutBuilder(
builder: (context, constraints) {
// Definiamo se siamo su uno schermo "Large" (es. sopra i 900px)
// Se lo schermo è più largo di 900px usiamo il layout Desktop
final bool isLargeScreen = constraints.maxWidth > 900;
return Scaffold(
body: Row(
children: [
// --- SIDEBAR (Solo per schermi grandi) ---
// --- SIDEBAR (Solo Desktop/Tablet) ---
if (isLargeScreen)
NavigationRail(
selectedIndex: _selectedIndex,
onDestinationSelected: (int index) {
setState(() => _selectedIndex = index);
},
extended:
constraints.maxWidth >
1200, // Si allarga se c'è molto spazio
labelType: constraints.maxWidth > 1200
? NavigationRailLabelType.none
: NavigationRailLabelType.all,
onDestinationSelected: (index) =>
setState(() => _selectedIndex = index),
extended: constraints.maxWidth > 1200,
backgroundColor: context.background,
selectedIconTheme: IconThemeData(color: context.accent),
selectedLabelTextStyle: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
),
unselectedLabelTextStyle: TextStyle(
color: context.secondaryText,
),
leading: _buildRailHeader(constraints.maxWidth > 1200),
destinations: const [
NavigationRailDestination(
@@ -59,18 +62,20 @@ class _HomeScreenState extends State<HomeScreen> {
],
),
// --- CONTENUTO PRINCIPALE ---
// --- CONTENUTO DINAMICO ---
Expanded(
child: _buildMainContent(context, state, isLargeScreen),
child: _buildPageContent(_selectedIndex, isLargeScreen),
),
],
),
// --- BOTTOM NAVIGATION (Solo per Mobile) ---
// --- BOTTOM BAR (Solo Mobile) ---
bottomNavigationBar: isLargeScreen
? null
: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) => setState(() => _selectedIndex = index),
selectedItemColor: context.accent,
unselectedItemColor: context.secondaryText,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.dashboard),
@@ -93,7 +98,6 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
// Header per la Sidebar (Logo o Icona)
Widget _buildRailHeader(bool isExtended) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
@@ -110,19 +114,17 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
Widget _buildMainContent(
BuildContext context,
SessionState state,
bool isLargeScreen,
) {
// Qui gestiamo lo switch tra le pagine
switch (_selectedIndex) {
// Switch tra le sottopagine
Widget _buildPageContent(int index, bool isLargeScreen) {
switch (index) {
case 0:
return DashboardContent(isLargeScreen: isLargeScreen);
case 1:
return const Center(child: Text('Pagina Clienti')); // La faremo!
return const Center(child: Text('Pagina Clienti (Coming Soon)'));
case 2:
return const Center(child: Text('Pagina Operazioni (Coming Soon)'));
default:
return const DashboardContent();
return DashboardContent(isLargeScreen: isLargeScreen);
}
}
}