13 Commits

Author SHA1 Message Date
71efc18c05 ticket migration 2026-05-06 01:18:14 +02:00
5214ea9745 migrated 2026-05-05 09:30:03 +02:00
1115d2cb87 df 2026-05-04 19:32:14 +02:00
94ad524bae reworked operation (#12)
Reviewed-on: #12
Co-authored-by: Mark M2 Macbook <marco@catelli.it>
Co-committed-by: Mark M2 Macbook <marco@catelli.it>
2026-05-04 15:36:42 +02:00
9f57207a39 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>
2026-04-29 11:40:17 +02:00
1dff8ab90d added password reset - resend invite link (#10)
Co-authored-by: Copilot <copilot@github.com>
Reviewed-on: #10
Co-authored-by: Mark M2 Macbook <marco@catelli.it>
Co-committed-by: Mark M2 Macbook <marco@catelli.it>
2026-04-29 09:20:17 +02:00
fe5d1bd9e4 win 2026-04-28 18:06:15 +02:00
d2ff2de673 Merge pull request 'feat-invite_staff' (#9) from feat-invite_staff into main
Reviewed-on: #9
2026-04-28 15:34:33 +02:00
32c97accd7 ottimo
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 15:33:38 +02:00
f86b52e236 fix 2026-04-28 11:05:03 +02:00
c49964e5c5 fixes
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 10:12:15 +02:00
77987258aa import inutile 2026-04-26 16:30:18 +02:00
6cbf0479a1 urca non ci credo, potrebbe già funzionare
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 16:29:31 +02:00
132 changed files with 9188 additions and 5829 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
*.log *.log
*.pyc *.pyc
*.swp *.swp
*.env
.DS_Store .DS_Store
.atom/ .atom/
.build/ .build/

View File

@@ -1 +1,16 @@
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
# Escludiamo i file generati per le lingue, così il linter non ci entra proprio
- "lib/generated/**"
- "lib/l10n/*.dart"
- "**/*.g.dart" # Già che ci siamo escludiamo tutti i file generati (tipo quelli di JsonSerializable)
- "**/*.freezed.dart"
linter:
rules:
diagnostic_describe_all_properties: false
public_member_api_docs: false
# Ti consiglio di aggiungere anche questa se usi molto i file generati
avoid_relative_lib_imports: true

View File

@@ -1,5 +1,8 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
// START: FlutterFire Configuration
id("com.google.gms.google-services")
// END: FlutterFire Configuration
id("kotlin-android") id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")

View File

@@ -0,0 +1,86 @@
{
"project_info": {
"project_number": "872447580790",
"project_id": "assistenza-catelli",
"storage_bucket": "assistenza-catelli.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:872447580790:android:193235afcc2920ce5d9d57",
"android_client_info": {
"package_name": "com.catelli.scans2"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:872447580790:android:9c6172d77b1d2cae5d9d57",
"android_client_info": {
"package_name": "com.catellisrl.assistenza"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:872447580790:android:425d21710d7682005d9d57",
"android_client_info": {
"package_name": "com.catellisrl.catelli_energy_comparator"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:872447580790:android:a1d8d57960451f935d9d57",
"android_client_info": {
"package_name": "com.catellisrl.flux"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBSxpdLDlPnN0xjejlX_5JL19BDeSzKOr8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -20,6 +20,9 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.11.1" apply false
// START: FlutterFire Configuration
id("com.google.gms.google-services") version("4.3.15") apply false
// END: FlutterFire Configuration
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }

File diff suppressed because one or more lines are too long

1
firebase.json Normal file
View File

@@ -0,0 +1 @@
{"flutter":{"platforms":{"android":{"default":{"projectId":"assistenza-catelli","appId":"1:872447580790:android:a1d8d57960451f935d9d57","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"assistenza-catelli","configurations":{"android":"1:872447580790:android:a1d8d57960451f935d9d57","ios":"1:872447580790:ios:a87d56c718aa61e05d9d57","macos":"1:872447580790:ios:a87d56c718aa61e05d9d57","web":"1:872447580790:web:10745e7f9afb447d5d9d57","windows":"1:872447580790:web:3b1623eda6abdac75d9d57"}}}}}}

3
l10n.yaml Normal file
View File

@@ -0,0 +1,3 @@
arb-dir: lib/l10n
template-arb-file: app_it.arb
output-localization-file: app_localizations.dart

View File

@@ -39,8 +39,35 @@ class SessionCubit extends Cubit<SessionState> {
} }
try { try {
// 1. CHI È QUESTO UTENTE? (Vediamo se ha un profilo staff, che sia Invitato o Admin)
StaffMemberModel? staff = await _repository.getStaffMemberByUserId(
user.id,
);
CompanyModel? company;
if (staff != null) {
// --- LA MAGIA DEL SENSORE ---
if (staff.hasJoined == false) {
// È la primissima volta che entra! Aggiorniamo il DB.
await _repository.updateStaffMember(staff.id!, {'has_joined': true});
// Aggiorniamo anche il nostro modello in memoria per questa sessione
staff = staff.copyWith(hasJoined: true);
}
company = await _repository.getCompanyById(staff.companyId);
} else {
// È l'Admin in onboarding
company = await _repository.getCompanyByOwnerId(user.id);
}
// 1. Controllo Azienda // 1. Controllo Azienda
final company = await _repository.getCompanyByOwnerId(user.id);
if (staff != null) {
// L'utente esiste già nel sistema! Carichiamo l'azienda per cui lavora
company = await _repository.getCompanyById(staff.companyId);
} else {
// L'utente non ha profilo. Probabilmente è l'Admin che ha appena
// fatto Sign Up e sta iniziando l'Onboarding
company = await _repository.getCompanyByOwnerId(user.id);
}
if (company == null) { if (company == null) {
return emit( return emit(
state.copyWith( state.copyWith(
@@ -69,7 +96,6 @@ class SessionCubit extends Cubit<SessionState> {
} }
// 3. Controllo Staff (Paziente Zero) // 3. Controllo Staff (Paziente Zero)
final staff = await _repository.getStaffMemberByUserId(user.id);
if (staff == null) { if (staff == null) {
return emit( return emit(
state.copyWith( state.copyWith(
@@ -102,7 +128,7 @@ class SessionCubit extends Cubit<SessionState> {
user: user, user: user,
company: company, company: company,
currentStore: activeStore, currentStore: activeStore,
currentStaff: staff, currentStaffMember: staff,
onboardingStep: OnboardingStep.none, // Svuotiamo l'onboarding onboardingStep: OnboardingStep.none, // Svuotiamo l'onboarding
), ),
); );

View File

@@ -22,7 +22,7 @@ class SessionState extends Equatable {
final User? user; // Utente di Supabase Auth final User? user; // Utente di Supabase Auth
final CompanyModel? company; final CompanyModel? company;
final StoreModel? currentStore; final StoreModel? currentStore;
final StaffMemberModel? currentStaff; final StaffMemberModel? currentStaffMember;
final OnboardingStep onboardingStep; final OnboardingStep onboardingStep;
final bool isMobileDevice; final bool isMobileDevice;
@@ -31,7 +31,7 @@ class SessionState extends Equatable {
this.user, this.user,
this.company, this.company,
this.currentStore, this.currentStore,
this.currentStaff, this.currentStaffMember,
this.onboardingStep = OnboardingStep.none, this.onboardingStep = OnboardingStep.none,
this.isMobileDevice = false, this.isMobileDevice = false,
}); });
@@ -42,7 +42,7 @@ class SessionState extends Equatable {
User? user, User? user,
CompanyModel? company, CompanyModel? company,
StoreModel? currentStore, StoreModel? currentStore,
StaffMemberModel? currentStaff, StaffMemberModel? currentStaffMember,
OnboardingStep? onboardingStep, OnboardingStep? onboardingStep,
bool? isMobileDevice, bool? isMobileDevice,
}) { }) {
@@ -51,7 +51,7 @@ class SessionState extends Equatable {
user: user ?? this.user, user: user ?? this.user,
company: company ?? this.company, company: company ?? this.company,
currentStore: currentStore ?? this.currentStore, currentStore: currentStore ?? this.currentStore,
currentStaff: currentStaff ?? this.currentStaff, currentStaffMember: currentStaffMember ?? this.currentStaffMember,
onboardingStep: onboardingStep ?? this.onboardingStep, onboardingStep: onboardingStep ?? this.onboardingStep,
isMobileDevice: isMobileDevice ?? this.isMobileDevice, isMobileDevice: isMobileDevice ?? this.isMobileDevice,
); );
@@ -63,7 +63,7 @@ class SessionState extends Equatable {
user, user,
company, company,
currentStore, currentStore,
currentStaff, currentStaffMember,
onboardingStep, onboardingStep,
isMobileDevice, isMobileDevice,
]; ];

View File

@@ -0,0 +1,2 @@
const String resetPasswordUrl =
'https://flux-web-invite.marco-6ba.workers.dev/';

View File

@@ -24,7 +24,22 @@ class CoreRepository {
return CompanyModel.fromMap(response); return CompanyModel.fromMap(response);
} catch (e) { } catch (e) {
debugPrint('Errore recupero azienda: $e'); debugPrint('Errore recupero azienda: $e');
throw Exception('Errore recupero azienda: $e'); throw Exception('$e');
}
}
Future<CompanyModel?> getCompanyById(String companyId) async {
try {
final response = await _supabase
.from('company')
.select()
.eq('id', companyId)
.maybeSingle();
if (response == null) return null;
return CompanyModel.fromMap(response);
} catch (e) {
debugPrint('$e');
return null;
} }
} }
@@ -35,12 +50,12 @@ class CoreRepository {
.select() .select()
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('is_active', true) // Buona pratica .eq('is_active', true) // Buona pratica
.order('nome'); // O come si chiama il campo nome .order('name'); // O come si chiama il campo nome
return (response as List).map((s) => StoreModel.fromMap(s)).toList(); return (response as List).map((s) => StoreModel.fromMap(s)).toList();
} catch (e) { } catch (e) {
debugPrint('Errore recupero negozi: $e'); debugPrint('Errore recupero negozi: $e');
throw Exception('Errore recupero negozi: $e'); throw Exception('$e');
} }
} }
@@ -56,7 +71,7 @@ class CoreRepository {
return StaffMemberModel.fromMap(response); return StaffMemberModel.fromMap(response);
} catch (e) { } catch (e) {
debugPrint('Errore recupero profilo staff: $e'); debugPrint('Errore recupero profilo staff: $e');
throw Exception('Errore recupero profilo staff: $e'); throw Exception('$e');
} }
} }
@@ -72,7 +87,7 @@ class CoreRepository {
return CompanyModel.fromMap(response); return CompanyModel.fromMap(response);
} catch (e) { } catch (e) {
debugPrint('Creazione azienda fallita: $e'); debugPrint('Creazione azienda fallita: $e');
throw Exception('Creazione azienda fallita: $e'); throw Exception('$e');
} }
} }
@@ -86,7 +101,7 @@ class CoreRepository {
return StoreModel.fromMap(response); return StoreModel.fromMap(response);
} catch (e) { } catch (e) {
debugPrint('Creazione negozio fallita: $e'); debugPrint('Creazione negozio fallita: $e');
throw Exception('Creazione negozio fallita: $e'); throw Exception('$e');
} }
} }
@@ -105,7 +120,22 @@ class CoreRepository {
return StaffMemberModel.fromMap(response); return StaffMemberModel.fromMap(response);
} catch (e) { } catch (e) {
debugPrint('Creazione profilo staff fallita: $e'); debugPrint('Creazione profilo staff fallita: $e');
throw Exception('Creazione profilo staff fallita: $e'); throw Exception('$e');
} }
} }
// Assegna un membro a un negozio
Future<void> assignStaffToStore(String staffId, String storeId) async {
await _supabase.from('staff_in_stores').insert({
'staff_member_id': staffId,
'store_id': storeId,
});
}
Future<void> updateStaffMember(
String staffId,
Map<String, dynamic> data,
) async {
await _supabase.from('staff_member').update(data).eq('id', staffId);
}
} }

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.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: [
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),
),
],
),
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,
),
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,
),
],
),
);
}
}

View File

@@ -1,77 +1,81 @@
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/features/customers/ui/customer_mobile_upload_screen.dart'; import 'package:flux/core/layout/app_shell.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/set_password_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/blocs/customers_cubit.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/blocs/product_cubit.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/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/master_data/providers/ui/providers_master_data_screen.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
import 'package:flux/features/master_data/store/ui/stores_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';
import 'package:flux/features/services/blocs/service_files_bloc.dart'; import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
import 'package:flux/features/services/models/service_model.dart'; import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/services/ui/service_form_screen/service_form_screen.dart'; import 'package:flux/features/operations/ui/operation_form_screen.dart';
import 'package:flux/features/services/ui/service_form_screen/service_mobile_upload_screen.dart'; import 'package:flux/features/operations/ui/operation_mobile_upload_screen.dart';
import 'package:flux/features/operations/ui/operations_screen.dart';
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';
// 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;
return isGoingToLogin ? null : '/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) {
// Se per sbaglio cerca di tornare al login o all'onboarding, if (isGoingToLogin || isGoingToOnboarding) return '/';
// lo rimbalziamo alla home.
if (isGoingToLogin || isGoingToOnboarding) {
return '/';
}
// Per tutte le altre rotte (dashboard, clienti, anagrafiche), lascialo passare.
return null; return null;
} }
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(
path: '/set-password',
builder: (context, state) => const SetPasswordScreen(),
),
GoRoute( GoRoute(
path: '/onboarding', path: '/onboarding',
builder: (context, state) => BlocProvider( builder: (context, state) => BlocProvider(
@@ -81,18 +85,74 @@ 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) {
context.read<ProductsCubit>().refreshCubit();
return const ProductsScreen();
},
), ),
GoRoute( GoRoute(
path: '/', path: 'staff', // Diventa /master-data/staff
builder: (context, state) => const HomeScreen(), // La tua home builder: (context, state) => const StaffScreen(),
), ),
GoRoute(
path: 'stores', // Diventa /master-data/stores
builder: (context, state) => const StoresScreen(),
),
GoRoute(
path: 'providers', // Diventa /master-data/providers
builder: (context, state) =>
const ProvidersMasterDataScreen(),
),
],
),
// 3. IMPOSTAZIONI
GoRoute(
path: '/settings',
builder: (context, state) => Scaffold(
appBar: AppBar(title: Text(context.l10n.commonSettings)),
body: Center(
child: ElevatedButton.icon(
onPressed: () => context.read<SessionCubit>().signOut(),
icon: const Icon(Icons.logout),
label: const Text("Esci da FLUX"),
),
),
),
),
GoRoute(
path: '/operations',
builder: (context, state) => const OperationsScreen(),
),
GoRoute(
path: '/customers',
builder: (context, state) =>
const CustomersContent(), // O come si chiama il tuo widget della lista!
),
],
),
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
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!),
@@ -104,10 +164,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(
@@ -118,41 +175,58 @@ class AppRouter {
}, },
), ),
GoRoute( GoRoute(
path: '/products', path: '/operation-form',
name: 'products', name: 'operation-form',
builder: (context, state) => const ProductsScreen(),
),
GoRoute(
path: '/service-form',
name: 'service-form',
builder: (context, state) { builder: (context, state) {
// Recuperiamo l'oggetto se passato tramite 'extra' final existingOperation = state.extra as OperationModel?;
final existingService = state.extra as ServiceModel?; final operationId = state.uri.queryParameters['operationId'];
// Recuperiamo l'ID se presente nell'URL final currentStoreId = GetIt.I
final serviceId = state.uri.queryParameters['serviceId']; .get<SessionCubit>()
.state
.currentStore!
.id!;
context.read<CustomersCubit>().loadCustomers();
context.read<ProvidersCubit>().loadActiveProvidersForStore(
currentStoreId,
);
context.read<ProductsCubit>().loadModels();
context.read<ProductsCubit>().loadBrands();
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
return BlocProvider( return BlocProvider(
create: (context) => create: (context) => OperationFilesBloc(
ServiceFilesBloc(serviceId: serviceId ?? existingService?.id), operationId: operationId ?? existingOperation?.id,
child: ServiceFormScreen( ),
serviceId: serviceId ?? existingService?.id, child: OperationFormScreen(
existingService: existingService, operationId: operationId ?? existingOperation?.id,
existingOperation: existingOperation,
), ),
); );
}, },
), ),
GoRoute( GoRoute(
path: '/service/:id/upload', path: '/operation/:id/upload',
builder: (context, state) { builder: (context, state) {
final serviceId = state.pathParameters['id']!; final operationId = state.pathParameters['id']!;
final serviceName = state.uri.queryParameters['name'] ?? 'Pratica'; final operationName =
state.uri.queryParameters['name'] ?? 'Pratica';
final currentStoreId = GetIt.I
.get<SessionCubit>()
.state
.currentStore!
.id!;
context.read<CustomersCubit>().loadCustomers();
context.read<ProvidersCubit>().loadActiveProvidersForStore(
currentStoreId,
);
context.read<ProductsCubit>().loadModels();
context.read<ProductsCubit>().loadBrands();
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
return BlocProvider( return BlocProvider(
// Inizializziamo il bloc col serviceId corretto! create: (context) => OperationFilesBloc(operationId: operationId),
create: (context) => ServiceFilesBloc(serviceId: serviceId), child: OperationMobileUploadScreen(
child: ServiceMobileUploadScreen( operationId: operationId,
serviceId: serviceId, operationName: operationName,
serviceName: serviceName,
), ),
); );
}, },
@@ -162,8 +236,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();
@@ -171,9 +243,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();

View File

@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
class AppMessage {
final String key;
final String? argument;
const AppMessage({required this.key, this.argument});
String translatedMessage(BuildContext context) {
switch (key) {
case 'authCubitCheckEmailToConfirmAccount':
return context.l10n.authCubitCheckEmailToConfirmAccount;
case 'authCubitResetPasswordEmailSentTo':
return context.l10n.authCubitResetPasswordEmailSentTo(argument!);
default:
return 'empty message';
}
}
}

View File

@@ -1,3 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flux/l10n/app_localizations.dart';
extension MyStringExtensions on String? { extension MyStringExtensions on String? {
// Gestiamo anche il nullable per sicurezza // Gestiamo anche il nullable per sicurezza
String myFormat() { String myFormat() {
@@ -40,3 +43,7 @@ extension MyStringExtensions on String? {
.join('.'); // Ritorna tutto tranne l'ultima parte .join('.'); // Ritorna tutto tranne l'ultima parte
} }
} }
extension LocalizationExtension on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this)!;
}

View File

@@ -20,6 +20,7 @@ class FluxTextField extends StatefulWidget {
final List<TextInputFormatter>? inputFormatters; final List<TextInputFormatter>? inputFormatters;
final TextCapitalization? textCapitalization; final TextCapitalization? textCapitalization;
final bool? autocorrect; final bool? autocorrect;
final bool? enabled;
const FluxTextField({ const FluxTextField({
super.key, // Usiamo super.key per Flutter moderno super.key, // Usiamo super.key per Flutter moderno
@@ -39,6 +40,7 @@ class FluxTextField extends StatefulWidget {
this.inputFormatters, this.inputFormatters,
this.textCapitalization, this.textCapitalization,
this.autocorrect, this.autocorrect,
this.enabled = true,
}); });
@override @override
@@ -115,6 +117,7 @@ class _FluxTextFieldState extends State<FluxTextField> {
inputFormatters: widget.inputFormatters, inputFormatters: widget.inputFormatters,
textCapitalization: widget.textCapitalization ?? TextCapitalization.none, textCapitalization: widget.textCapitalization ?? TextCapitalization.none,
enabled: widget.enabled,
); );
} }
} }

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/utils/functions.dart'; import 'package:flux/core/utils/functions.dart';
class ImageViewerWidget extends StatelessWidget { class ImageViewerWidget extends StatelessWidget {
@@ -36,8 +37,8 @@ class ImageViewerWidget extends StatelessWidget {
return const CircularProgressIndicator(); return const CircularProgressIndicator();
} }
if (snapshot.hasError) { if (snapshot.hasError) {
return const Text( return Text(
"Errore caricamento immagine (Permessi negati?)", context.l10n.imageViewerWidgetErrorOpening,
style: TextStyle(color: Colors.red), style: TextStyle(color: Colors.red),
); );
} }

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/utils/functions.dart'; import 'package:flux/core/utils/functions.dart';
import 'package:pdfx/pdfx.dart'; import 'package:pdfx/pdfx.dart';
import 'package:internet_file/internet_file.dart'; import 'package:internet_file/internet_file.dart';
@@ -74,13 +75,13 @@ class _PdfViewerWidgetState extends State<PdfViewerWidget> {
if (_errorMessage != null) { if (_errorMessage != null) {
return Scaffold( return Scaffold(
appBar: AppBar(leading: const CloseButton()), appBar: AppBar(leading: const CloseButton()),
body: Center(child: Text("Errore: $_errorMessage")), body: Center(child: Text(context.l10n.commonError(_errorMessage!))),
); );
} }
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("Anteprima PDF"), title: Text(context.l10n.pdfViewerAnteprimaPdf),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
class QrUploadDialog extends StatelessWidget { class QrUploadDialog extends StatelessWidget {
@@ -84,7 +85,7 @@ class QrUploadDialog extends StatelessWidget {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text("CHIUDI"), child: Text(context.l10n.commonClose),
), ),
], ],
actionsAlignment: MainAxisAlignment.center, actionsAlignment: MainAxisAlignment.center,

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:go_router/go_router.dart';
class SetPasswordScreen extends StatefulWidget {
const SetPasswordScreen({super.key});
@override
State<SetPasswordScreen> createState() => _SetPasswordScreenState();
}
class _SetPasswordScreenState extends State<SetPasswordScreen> {
final _passwordCtrl = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _savePassword() async {
final newPassword = _passwordCtrl.text.trim();
if (newPassword.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.setPasswordScreenAtLeast6Chars)),
);
return;
}
setState(() => _isLoading = true);
try {
// 1. Aggiorniamo la password dell'utente (che Supabase ha già loggato grazie al link della mail)
await GetIt.I.get<SupabaseClient>().auth.updateUser(
UserAttributes(password: newPassword),
);
// 2. Finito! Lo mandiamo alla home o facciamo ricaricare la sessione al SessionCubit
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.setPasswordScreenPasswordSetWelcome),
),
);
context.go('/'); // Rimandiamo al router principale
}
} on AuthException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.authError(e.message))),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.commonError(e.toString()))),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.setPasswordScreenWelcomeInFlux),
automaticallyImplyLeading:
false, // Non può tornare indietro, deve mettere la password!
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.lock_reset, size: 80, color: Colors.blueAccent),
const SizedBox(height: 24),
Text(
context.l10n.setPasswordScreenSetPassword,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
context.l10n.setPasswordInviteAcceptedChoosePassword,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 32),
FluxTextField(
controller: _passwordCtrl,
label: context.l10n.commonNewPassword,
icon: Icons.lock,
isPassword: true,
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _isLoading ? null : _savePassword,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: Text(
context.l10n.setPasswordScreenSaveAndStart,
style: TextStyle(fontSize: 16),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,23 @@
import 'dart:typed_data';
import 'package:supabase_flutter/supabase_flutter.dart';
class AttachmentsRepository {
final _supabase = Supabase.instance.client;
/// Scarica i byte di un file direttamente da Supabase Storage
Future<Uint8List> downloadAttachmentBytes(String storagePath) async {
try {
// ATTENZIONE: Sostituisci 'attachments' con il nome VERO del tuo bucket su Supabase!
// Se il tuo storagePath contiene già il nome del bucket all'inizio,
// assicurati di passargli solo il percorso interno.
final Uint8List bytes = await _supabase.storage
.from('attachments') // <--- NOME DEL TUO BUCKET
.download(storagePath);
return bytes;
} catch (e) {
throw Exception("Impossibile scaricare il documento dal cloud: $e");
}
}
}

View File

@@ -2,30 +2,49 @@ import 'dart:typed_data';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
class ServiceFileModel extends Equatable { class AttachmentModel extends Equatable {
final String? id; final String? id;
final DateTime? createdAt; final DateTime? createdAt;
final String? customerId;
final String? operationId;
final String name; final String name;
final String extension; final String extension;
final String storagePath; final String? storagePath;
final String serviceId;
final int fileSize; final int fileSize;
final Uint8List? localBytes; final Uint8List? localBytes;
final String companyId;
const ServiceFileModel({ const AttachmentModel({
this.id, this.id,
this.createdAt, this.createdAt,
this.customerId,
this.operationId,
required this.name, required this.name,
required this.extension, required this.extension,
required this.storagePath, this.storagePath,
required this.serviceId,
required this.fileSize, required this.fileSize,
this.localBytes, this.localBytes,
required this.companyId,
}); });
@override
List<Object?> get props => [
id,
createdAt,
customerId,
operationId,
name,
extension,
storagePath,
fileSize,
localBytes,
companyId,
];
bool get isLocal => localBytes != null; bool get isLocal => localBytes != null;
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB) bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
String get sizeFormatted { String get sizeFormatted {
if (fileSize <= 0) return "0 B"; if (fileSize <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"]; const suffixes = ["B", "KB", "MB", "GB", "TB"];
@@ -35,43 +54,45 @@ class ServiceFileModel extends Equatable {
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}"; return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
} }
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf'; AttachmentModel copyWith({
ServiceFileModel copyWith({
String? id, String? id,
DateTime? createdAt, DateTime? createdAt,
String? customerId,
String? operationId,
String? name, String? name,
String? extension, String? extension,
String? storagePath, String? storagePath,
String? serviceId,
int? fileSize, int? fileSize,
Uint8List? localBytes, Uint8List? localBytes,
}) { String? companyId,
return ServiceFileModel( }) => AttachmentModel(
id: id ?? this.id, id: id ?? this.id,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
customerId: customerId ?? this.customerId,
operationId: operationId ?? this.operationId,
name: name ?? this.name, name: name ?? this.name,
extension: extension ?? this.extension, extension: extension ?? this.extension,
storagePath: storagePath ?? this.storagePath, storagePath: storagePath ?? this.storagePath,
serviceId: serviceId ?? this.serviceId,
fileSize: fileSize ?? this.fileSize, fileSize: fileSize ?? this.fileSize,
localBytes: localBytes ?? this.localBytes, localBytes: localBytes ?? this.localBytes,
companyId: companyId ?? this.companyId,
); );
}
factory ServiceFileModel.fromMap(Map<String, dynamic> map) { factory AttachmentModel.fromMap(Map<String, dynamic> map) {
return ServiceFileModel( return AttachmentModel(
id: map['id'] as String, id: map['id'] as String,
createdAt: map['created_at'] != null createdAt: map['created_at'] != null
? DateTime.parse(map['created_at']) ? DateTime.parse(map['created_at'])
: null, : null,
name: map['name'] ?? '', customerId: map['customer_id'] as String?,
extension: map['extension'] ?? '', operationId: map['operation_id'] as String?,
storagePath: map['storage_path'] ?? '', name: map['name'] as String,
serviceId: map['service_id']?.toString() ?? '', extension: map['extension'] as String,
storagePath: map['storage_path'] as String?,
fileSize: map['file_size'] is int fileSize: map['file_size'] is int
? map['file_size'] ? map['file_size']
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0, : int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
companyId: map['company_id'] as String,
); );
} }
@@ -81,20 +102,10 @@ class ServiceFileModel extends Equatable {
'name': name, 'name': name,
'extension': extension, 'extension': extension,
'storage_path': storagePath, 'storage_path': storagePath,
'service_id': serviceId, 'customer_id': customerId,
'operation_id': operationId,
'file_size': fileSize, 'file_size': fileSize,
'company_id': companyId,
}; };
} }
@override
List<Object?> get props => [
id,
createdAt,
name,
extension,
storagePath,
serviceId,
fileSize,
localBytes,
];
} }

View File

@@ -0,0 +1,220 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flux/core/utils/functions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:pdfx/pdfx.dart';
import 'package:internet_file/internet_file.dart';
class AttachmentViewerScreen extends StatefulWidget {
final AttachmentModel attachment;
final Function(String newName)? onRename;
final VoidCallback? onDelete;
const AttachmentViewerScreen({
super.key,
required this.attachment,
this.onRename,
this.onDelete,
});
@override
State<AttachmentViewerScreen> createState() => _AttachmentViewerScreenState();
}
class _AttachmentViewerScreenState extends State<AttachmentViewerScreen> {
PdfControllerPinch? _pdfController;
bool _isLoading = true;
String? _errorMessage;
Uint8List? _fileBytes;
late String _fileName;
bool get isPdf => widget.attachment.extension.toLowerCase() == 'pdf';
@override
void initState() {
super.initState();
_fileName = widget.attachment.name;
_loadFile();
}
Future<void> _loadFile() async {
try {
// 1. Capiamo da dove prendere i dati
if (widget.attachment.localBytes != null) {
_fileBytes = widget.attachment.localBytes;
} else if (widget.attachment.storagePath != null &&
widget.attachment.storagePath!.isNotEmpty) {
final signedUrl = await getSignedUrl(widget.attachment.storagePath!);
_fileBytes = await InternetFile.get(signedUrl);
} else {
throw Exception("Nessun documento trovato o byte mancanti.");
}
// 2. Se è PDF, inizializziamo il controller
if (isPdf && _fileBytes != null) {
_pdfController = PdfControllerPinch(
document: PdfDocument.openData(_fileBytes!),
);
}
if (mounted) setState(() => _isLoading = false);
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
}
}
}
@override
void dispose() {
_pdfController?.dispose();
super.dispose();
}
void _showRenameDialog() {
final ctrl = TextEditingController(text: _fileName);
ctrl.selection = TextSelection(
baseOffset: 0,
extentOffset: ctrl.text.length,
);
final focusNode = FocusNode();
showDialog(
context: context,
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => focusNode.requestFocus(),
);
return AlertDialog(
title: const Text('Rinomina File'),
content: TextField(
controller: ctrl,
focusNode: focusNode,
decoration: InputDecoration(
labelText: 'Nuovo nome',
suffixText: '.${widget.attachment.extension}',
),
onSubmitted: (val) {
Navigator.pop(context);
if (val.trim().isNotEmpty && widget.onRename != null) {
setState(() {
_fileName = val.trim();
});
widget.onRename!(val.trim());
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annulla'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
if (ctrl.text.trim().isNotEmpty && widget.onRename != null) {
setState(() {
_fileName = ctrl.text.trim();
});
widget.onRename!(ctrl.text.trim());
}
},
child: const Text('Salva'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black87, // Sfondo scuro per i viewer è il top
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
actions: [
if (widget.onRename != null)
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Rinomina',
onPressed: _showRenameDialog,
),
if (widget.onDelete != null)
IconButton(
icon: const Icon(Icons.delete, color: Colors.redAccent),
tooltip: 'Elimina',
onPressed: () {
// Chiediamo conferma
showDialog(
context: context,
builder: (c) => AlertDialog(
title: const Text('Eliminare file?'),
content: const Text(
'Sei sicuro di voler eliminare questo allegato?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('Annulla'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
onPressed: () {
Navigator.pop(c); // Chiude dialog
widget.onDelete!(); // Lancia eliminazione
Navigator.pop(context); // Chiude il viewer
},
child: const Text('Elimina'),
),
],
),
);
},
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_errorMessage != null) {
return Center(
child: Text(
'Errore: $_errorMessage',
style: const TextStyle(color: Colors.redAccent),
),
);
}
if (_fileBytes == null) {
return const Center(
child: Text(
'File non disponibile',
style: TextStyle(color: Colors.white),
),
);
}
if (isPdf && _pdfController != null) {
return PdfViewPinch(controller: _pdfController!);
} else {
return InteractiveViewer(
maxScale: 5.0,
child: Center(child: Image.memory(_fileBytes!)),
);
}
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
class QuickRenameDialog extends StatefulWidget {
final String suggestedName;
final Widget previewWidget; // Può essere Image.memory o un'icona PDF
const QuickRenameDialog({
super.key,
required this.suggestedName,
required this.previewWidget,
});
@override
State<QuickRenameDialog> createState() => _QuickRenameDialogState();
}
class _QuickRenameDialogState extends State<QuickRenameDialog> {
late TextEditingController _nameCtrl;
final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.suggestedName);
// MAGIA UX: Selezioniamo tutto il testo di default appena si apre!
_nameCtrl.selection = TextSelection(
baseOffset: 0,
extentOffset: widget.suggestedName.length,
);
// Richiediamo il focus appena il widget è costruito
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_nameCtrl.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Rinomina per Export'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Anteprima del documento (limitiamo l'altezza)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(border: Border.all(color: Colors.grey)),
child: widget.previewWidget,
),
const SizedBox(height: 16),
TextField(
controller: _nameCtrl,
focusNode: _focusNode,
decoration: const InputDecoration(
labelText: 'Nome del file',
suffixText: '.pdf', // Facciamo capire che sarà un PDF
border: OutlineInputBorder(),
),
// MAGIA UX 2: Se preme invio sulla tastiera, salva e chiude!
onSubmitted: (value) => Navigator.of(context).pop(value),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(), // Ritorna null
child: const Text('Salta'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(_nameCtrl.text),
child: const Text('Esporta (Invio)'),
),
],
);
}
}

View File

@@ -1,6 +1,8 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.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/data/constants.dart';
import 'package:flux/core/utils/app_message.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
part 'auth_state.dart'; part 'auth_state.dart';
@@ -40,7 +42,9 @@ class AuthCubit extends Cubit<AuthState> {
emit( emit(
state.copyWith( state.copyWith(
status: AuthStatus.initial, status: AuthStatus.initial,
infoMessage: "Controlla la tua email per confermare l'account!", infoMessage: AppMessage(
key: 'authCubitCheckEmailToConfirmAccount',
),
), ),
); );
} else { } else {
@@ -64,6 +68,31 @@ class AuthCubit extends Cubit<AuthState> {
} }
} }
Future<void> requestPasswordReset(String email) async {
if (email.isEmpty) {
emit(
state.copyWith(
status: AuthStatus.failure,
errorMessage: 'Devi inserire l\'indirizzo email',
),
);
return;
}
await _supabase.auth.resetPasswordForEmail(
email,
redirectTo: resetPasswordUrl,
);
emit(
state.copyWith(
status: AuthStatus.pwResetSent,
infoMessage: AppMessage(
key: 'authCubitResetPasswordEmailSentTo',
argument: email,
),
),
);
}
Future<void> requestLogout() async { Future<void> requestLogout() async {
await _supabase.auth.signOut(); await _supabase.auth.signOut();
emit(state.copyWith(status: AuthStatus.initial)); emit(state.copyWith(status: AuthStatus.initial));

View File

@@ -1,12 +1,12 @@
part of 'auth_cubit.dart'; part of 'auth_cubit.dart';
enum AuthStatus { initial, loading, failure } enum AuthStatus { initial, pwResetSent, loading, failure }
class AuthState extends Equatable { class AuthState extends Equatable {
final AuthStatus status; final AuthStatus status;
final bool isLoginMode; final bool isLoginMode;
final String? errorMessage; final String? errorMessage;
final String? infoMessage; final AppMessage? infoMessage;
const AuthState({ const AuthState({
this.status = AuthStatus.initial, this.status = AuthStatus.initial,
@@ -19,7 +19,7 @@ class AuthState extends Equatable {
AuthStatus? status, AuthStatus? status,
bool? isLoginMode, bool? isLoginMode,
String? errorMessage, String? errorMessage,
String? infoMessage, AppMessage? infoMessage,
}) { }) {
return AuthState( return AuthState(
status: status ?? this.status, status: status ?? this.status,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/flux_logo.dart'; import 'package:flux/core/widgets/flux_logo.dart';
import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/auth/bloc/auth_cubit.dart'; import 'package:flux/features/auth/bloc/auth_cubit.dart';
@@ -55,7 +56,7 @@ class _AuthScreenState extends State<AuthScreen> {
if (state.infoMessage != null) { if (state.infoMessage != null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(state.infoMessage!), content: Text(state.infoMessage!.translatedMessage(context)),
backgroundColor: Colors.blueAccent, // O context.accent backgroundColor: Colors.blueAccent, // O context.accent
), ),
); );
@@ -77,7 +78,9 @@ class _AuthScreenState extends State<AuthScreen> {
// --- TITOLO DINAMICO --- // --- TITOLO DINAMICO ---
Text( Text(
state.isLoginMode ? 'BENTORNATO' : 'CREA ACCOUNT', state.isLoginMode
? context.l10n.authScreenWelcomeBack
: context.l10n.authScreenCreateAccount,
style: TextStyle( style: TextStyle(
color: context.primaryText, color: context.primaryText,
fontSize: 24, fontSize: 24,
@@ -88,8 +91,10 @@ class _AuthScreenState extends State<AuthScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
state.isLoginMode state.isLoginMode
? 'Accedi per gestire il tuo business' ? context.l10n.authScreenLoginToManageYourBusiness
: 'Inizia oggi a digitalizzare il tuo negozio', : context
.l10n
.authScreenStartTodayToDigitalizeYourStore,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: context.secondaryText), style: TextStyle(color: context.secondaryText),
), ),
@@ -97,7 +102,7 @@ class _AuthScreenState extends State<AuthScreen> {
// --- CAMPI INPUT --- // --- CAMPI INPUT ---
FluxTextField( FluxTextField(
label: 'Email Aziendale', label: context.l10n.authScreenBusinessEmail,
icon: Icons.email_outlined, icon: Icons.email_outlined,
controller: _emailController, controller: _emailController,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
@@ -130,7 +135,9 @@ class _AuthScreenState extends State<AuthScreen> {
), ),
) )
: Text( : Text(
state.isLoginMode ? 'ACCEDI' : 'REGISTRATI', state.isLoginMode
? context.l10n.authScreenLogin
: context.l10n.authScreenSignUp,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -147,12 +154,15 @@ class _AuthScreenState extends State<AuthScreen> {
child: RichText( child: RichText(
text: TextSpan( text: TextSpan(
text: state.isLoginMode text: state.isLoginMode
? "Non hai un account? " ? context.l10n.authScreenDontHaveAccount
: "Hai già un account? ", : context.l10n.authScreenAlreadyHaveAccount,
style: TextStyle(color: context.secondaryText), style: TextStyle(color: context.secondaryText),
children: [ children: [
TextSpan( TextSpan(
text: state.isLoginMode ? "Registrati" : "Accedi", text: state.isLoginMode
? context.l10n.authScreenSignUp
: context.l10n.authScreenLogin,
style: TextStyle( style: TextStyle(
color: context.accent, color: context.accent,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -162,6 +172,21 @@ class _AuthScreenState extends State<AuthScreen> {
), ),
), ),
), ),
if (state.isLoginMode) ...[
const SizedBox(height: 24),
TextButton(
onPressed: () => context
.read<AuthCubit>()
.requestPasswordReset(_emailController.text.trim()),
child: Text(
context.l10n.authScreenForgotPassword,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
),
),
),
],
], ],
), ),
), ),

View File

@@ -22,5 +22,5 @@ class CompanyState extends Equatable {
} }
@override @override
List<Object?> get props => [status, errorMessage]; List<Object?> get props => [status, errorMessage, company];
} }

View File

@@ -17,7 +17,7 @@ class CompanyRepository {
} on PostgrestException catch (e) { } on PostgrestException catch (e) {
throw e.message; throw e.message;
} catch (e) { } catch (e) {
throw 'Errore imprevisto durante la creazione dell\'azienda'; throw e.toString();
} }
} }

View File

@@ -45,14 +45,14 @@ class CompanyModel extends Equatable {
final String userId; // Nel DB è user_id (chiave esterna su auth.users) final String userId; // Nel DB è user_id (chiave esterna su auth.users)
// Dati Anagrafici e Fatturazione // Dati Anagrafici e Fatturazione
final String ragioneSociale; final String name;
final String indirizzo; final String address;
final String cap; final String zipCode;
final String citta; final String city;
final String provincia; final String province;
final String partitaIva; final String vatId;
final String codiceFiscale; final String fiscalCode;
final String codiceUnivoco; final String sdi;
final String companyLogo; final String companyLogo;
// Stato Pagamenti (Ibride: manuale + Stripe) // Stato Pagamenti (Ibride: manuale + Stripe)
@@ -70,14 +70,14 @@ class CompanyModel extends Equatable {
this.id, this.id,
this.createdAt, this.createdAt,
required this.userId, required this.userId,
required this.ragioneSociale, required this.name,
required this.indirizzo, required this.address,
required this.cap, required this.zipCode,
required this.citta, required this.city,
required this.provincia, required this.province,
required this.partitaIva, required this.vatId,
required this.codiceFiscale, required this.fiscalCode,
required this.codiceUnivoco, required this.sdi,
this.companyLogo = '', this.companyLogo = '',
this.isPaid = false, this.isPaid = false,
this.paymentExpiration, this.paymentExpiration,
@@ -92,14 +92,14 @@ class CompanyModel extends Equatable {
String? id, String? id,
DateTime? createdAt, DateTime? createdAt,
String? userId, String? userId,
String? ragioneSociale, String? name,
String? indirizzo, String? address,
String? cap, String? zipCode,
String? citta, String? city,
String? provincia, String? province,
String? partitaIva, String? vatId,
String? codiceFiscale, String? fiscalCode,
String? codiceUnivoco, String? sdi,
String? companyLogo, String? companyLogo,
bool? isPaid, bool? isPaid,
DateTime? paymentExpiration, DateTime? paymentExpiration,
@@ -113,14 +113,14 @@ class CompanyModel extends Equatable {
id: id ?? this.id, id: id ?? this.id,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
userId: userId ?? this.userId, userId: userId ?? this.userId,
ragioneSociale: ragioneSociale ?? this.ragioneSociale, name: name ?? this.name,
indirizzo: indirizzo ?? this.indirizzo, address: address ?? this.address,
cap: cap ?? this.cap, zipCode: zipCode ?? this.zipCode,
citta: citta ?? this.citta, city: city ?? this.city,
provincia: provincia ?? this.provincia, province: province ?? this.province,
partitaIva: partitaIva ?? this.partitaIva, vatId: vatId ?? this.vatId,
codiceFiscale: codiceFiscale ?? this.codiceFiscale, fiscalCode: fiscalCode ?? this.fiscalCode,
codiceUnivoco: codiceUnivoco ?? this.codiceUnivoco, sdi: sdi ?? this.sdi,
companyLogo: companyLogo ?? this.companyLogo, companyLogo: companyLogo ?? this.companyLogo,
isPaid: isPaid ?? this.isPaid, isPaid: isPaid ?? this.isPaid,
paymentExpiration: paymentExpiration ?? this.paymentExpiration, paymentExpiration: paymentExpiration ?? this.paymentExpiration,
@@ -137,14 +137,14 @@ class CompanyModel extends Equatable {
id: null, id: null,
createdAt: null, createdAt: null,
userId: '', userId: '',
ragioneSociale: '', name: '',
indirizzo: '', address: '',
cap: '', zipCode: '',
citta: '', city: '',
provincia: '', province: '',
partitaIva: '', vatId: '',
codiceFiscale: '', fiscalCode: '',
codiceUnivoco: '', sdi: '',
); );
} }
@@ -155,14 +155,14 @@ class CompanyModel extends Equatable {
? DateTime.tryParse(map['created_at']) ? DateTime.tryParse(map['created_at'])
: null, : null,
userId: map['user_id'] ?? '', userId: map['user_id'] ?? '',
ragioneSociale: map['ragione_sociale'] ?? '', name: map['name'] ?? '',
indirizzo: map['indirizzo'] ?? '', address: map['address'] ?? '',
cap: map['cap'] ?? '', zipCode: map['zip_code'] ?? '',
citta: map['citta'] ?? '', city: map['city'] ?? '',
provincia: map['provincia'] ?? '', province: map['province'] ?? '',
partitaIva: map['partita_iva'] ?? '', vatId: map['vat_id'] ?? '',
codiceFiscale: map['codice_fiscale'] ?? '', fiscalCode: map['fiscal_code'] ?? '',
codiceUnivoco: map['codice_univoco'] ?? '', sdi: map['sdi'] ?? '',
companyLogo: map['company_logo'] ?? '', companyLogo: map['company_logo'] ?? '',
isPaid: map['is_paid'] ?? false, isPaid: map['is_paid'] ?? false,
paymentExpiration: map['payment_expiration'] != null paymentExpiration: map['payment_expiration'] != null
@@ -185,14 +185,14 @@ class CompanyModel extends Equatable {
if (id != null) 'id': id, if (id != null) 'id': id,
// created_at è gestito dal DB di default, di solito non si passa nell'insert // created_at è gestito dal DB di default, di solito non si passa nell'insert
'user_id': userId, 'user_id': userId,
'ragione_sociale': ragioneSociale, 'name': name,
'indirizzo': indirizzo, 'address': address,
'cap': cap, 'zip_code': zipCode,
'citta': citta, 'city': city,
'provincia': provincia, 'province': province,
'partita_iva': partitaIva, 'vat_id': vatId,
'codice_fiscale': codiceFiscale, 'fiscal_code': fiscalCode,
'codice_univoco': codiceUnivoco, 'sdi': sdi,
'company_logo': companyLogo, 'company_logo': companyLogo,
'is_paid': isPaid, 'is_paid': isPaid,
if (paymentExpiration != null) if (paymentExpiration != null)
@@ -213,14 +213,14 @@ class CompanyModel extends Equatable {
id, id,
createdAt, createdAt,
userId, userId,
ragioneSociale, name,
indirizzo, address,
cap, zipCode,
citta, city,
provincia, province,
partitaIva, vatId,
codiceFiscale, fiscalCode,
codiceUnivoco, sdi,
companyLogo, companyLogo,
isPaid, isPaid,
paymentExpiration, paymentExpiration,
@@ -263,7 +263,7 @@ extension CompanyLimits on CompanyModel {
} }
} }
int get maxServicesPerMonth { int get maxOperationsPerMonth {
switch (subscriptionTier) { switch (subscriptionTier) {
case SubscriptionTier.free: case SubscriptionTier.free:
return 50; return 50;

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/company/bloc/company_bloc.dart'; import 'package:flux/features/company/bloc/company_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';
@@ -49,14 +50,14 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
final company = CompanyModel( final company = CompanyModel(
userId: userId, userId: userId,
ragioneSociale: _ragioneSocialeController.text.trim(), name: _ragioneSocialeController.text.trim(),
indirizzo: _indirizzoController.text.trim(), address: _indirizzoController.text.trim(),
cap: _capController.text.trim(), zipCode: _capController.text.trim(),
citta: _cittaController.text.trim(), city: _cittaController.text.trim(),
provincia: _provinciaController.text.trim(), province: _provinciaController.text.trim(),
partitaIva: _pIvaController.text.trim(), vatId: _pIvaController.text.trim(),
codiceFiscale: _cfController.text.trim(), fiscalCode: _cfController.text.trim(),
codiceUnivoco: _univocoController.text.trim().toUpperCase(), sdi: _univocoController.text.trim().toUpperCase(),
// Gli altri campi hanno i default nel modello // Gli altri campi hanno i default nel modello
); );
@@ -69,7 +70,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Configurazione Azienda'), title: Text(context.l10n.createCompanyScreenCompanyConfiguration),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.logout_rounded), icon: const Icon(Icons.logout_rounded),
@@ -98,7 +99,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
state.errorMessage ?? 'Errore durante il salvataggio', state.errorMessage ?? context.l10n.commonSavingError,
), ),
backgroundColor: Colors.redAccent, backgroundColor: Colors.redAccent,
), ),
@@ -118,10 +119,12 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
// --- SEZIONE 1: IDENTITÀ FISCALE --- // --- SEZIONE 1: IDENTITÀ FISCALE ---
_SectionTitle(title: 'DATI FISCALI'), _SectionTitle(
title: context.l10n.createCompanyScreenFiscalData,
),
const SizedBox(height: 16), const SizedBox(height: 16),
FluxTextField( FluxTextField(
label: 'Ragione Sociale', label: context.l10n.createCompanyScreenCompanyName,
icon: Icons.business, icon: Icons.business,
controller: _ragioneSocialeController, controller: _ragioneSocialeController,
), ),
@@ -130,7 +133,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
children: [ children: [
Expanded( Expanded(
child: FluxTextField( child: FluxTextField(
label: 'Partita IVA', label: context.l10n.createCompanyScreenVatId,
icon: Icons.numbers, icon: Icons.numbers,
controller: _pIvaController, controller: _pIvaController,
), ),
@@ -138,7 +141,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: FluxTextField( child: FluxTextField(
label: 'Codice Fiscale', label: context.l10n.createCompanyScreenFiscalCode,
icon: Icons.badge_outlined, icon: Icons.badge_outlined,
controller: _cfController, controller: _cfController,
), ),
@@ -147,7 +150,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FluxTextField( FluxTextField(
label: 'Codice Univoco (SDI) / PEC', label: context.l10n.createCompanyScreenSdiPec,
icon: Icons.send_and_archive_outlined, icon: Icons.send_and_archive_outlined,
controller: _univocoController, controller: _univocoController,
), ),
@@ -155,10 +158,13 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
// --- SEZIONE 2: SEDE LEGALE --- // --- SEZIONE 2: SEDE LEGALE ---
_SectionTitle(title: 'SEDE LEGALE'), _SectionTitle(
title:
context.l10n.createCompanyScreenCompanyLegalAddress,
),
const SizedBox(height: 16), const SizedBox(height: 16),
FluxTextField( FluxTextField(
label: 'Indirizzo e n. civico', label: context.l10n.commonAddress,
icon: Icons.home_work_outlined, icon: Icons.home_work_outlined,
controller: _indirizzoController, controller: _indirizzoController,
), ),
@@ -168,7 +174,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
Expanded( Expanded(
flex: 2, flex: 2,
child: FluxTextField( child: FluxTextField(
label: 'Città', label: context.l10n.commonCity,
icon: Icons.location_city, icon: Icons.location_city,
controller: _cittaController, controller: _cittaController,
), ),
@@ -176,7 +182,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: FluxTextField( child: FluxTextField(
label: 'CAP', label: context.l10n.commonZipCode,
icon: Icons.map_outlined, icon: Icons.map_outlined,
controller: _capController, controller: _capController,
), ),
@@ -184,7 +190,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: FluxTextField( child: FluxTextField(
label: 'Prov', label: context.l10n.commonProvince,
icon: Icons.explore_outlined, icon: Icons.explore_outlined,
controller: _provinciaController, controller: _provinciaController,
), ),
@@ -232,7 +238,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
Icon(Icons.cloud_upload_outlined, color: context.accent, size: 32), Icon(Icons.cloud_upload_outlined, color: context.accent, size: 32),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Carica Logo Aziendale', context.l10n.createCompanyScreenUploadLogo,
style: TextStyle( style: TextStyle(
color: context.primaryText, color: context.primaryText,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -240,7 +246,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Verrà usato per le tue stampe e ricevute', context.l10n.createCompanyScreenWillBeUsedForReceipts,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: context.secondaryText, fontSize: 12), style: TextStyle(color: context.secondaryText, fontSize: 12),
), ),
@@ -259,7 +265,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
: () => _onSave(), : () => _onSave(),
child: state.status == CompanyStatus.loading child: state.status == CompanyStatus.loading
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: const Text('SALVA AZIENDA'), : Text(context.l10n.createCompanyScreenSaveCompany),
), ),
); );
} }
@@ -282,7 +288,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'Configura la tua Azienda', context.l10n.createCompanyScreenSetupYourCompany,
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: context.primaryText, color: context.primaryText,
@@ -290,7 +296,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi.', context.l10n.createCompanyScreenFluxNeedsYourFiscalData,
style: TextStyle( style: TextStyle(
color: context.secondaryText, color: context.secondaryText,
fontSize: 15, fontSize: 15,

View File

@@ -4,8 +4,8 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
part 'customer_files_events.dart'; part 'customer_files_events.dart';
@@ -26,7 +26,7 @@ class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
LoadCustomerFilesEvent event, LoadCustomerFilesEvent event,
Emitter<CustomerFilesState> emit, Emitter<CustomerFilesState> emit,
) async { ) async {
await emit.forEach<List<CustomerFileModel>>( await emit.forEach<List<AttachmentModel>>(
_repository.getCustomerFilesStream(customerId), _repository.getCustomerFilesStream(customerId),
onData: (customerFiles) => CustomerFilesState( onData: (customerFiles) => CustomerFilesState(
status: CustomerFilesStatus.success, status: CustomerFilesStatus.success,
@@ -128,7 +128,7 @@ class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
ToggleCustomerFileSelectionEvent event, ToggleCustomerFileSelectionEvent event,
Emitter<CustomerFilesState> emit, Emitter<CustomerFilesState> emit,
) { ) {
List<CustomerFileModel> selectedFiles = List.from(state.selectedFiles); List<AttachmentModel> selectedFiles = List.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) { if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file); selectedFiles.remove(event.file);
} else { } else {

View File

@@ -25,6 +25,6 @@ class UploadMultipleCustomerFilesEvent extends CustomerFilesEvent {
class DeleteCustomerFilesEvent extends CustomerFilesEvent {} class DeleteCustomerFilesEvent extends CustomerFilesEvent {}
class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent { class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent {
final CustomerFileModel file; final AttachmentModel file;
const ToggleCustomerFileSelectionEvent(this.file); const ToggleCustomerFileSelectionEvent(this.file);
} }

View File

@@ -12,8 +12,8 @@ class CustomerFilesState extends Equatable {
final CustomerFilesStatus status; final CustomerFilesStatus status;
final String? error; final String? error;
final List<CustomerFileModel> customerFiles; final List<AttachmentModel> customerFiles;
final List<CustomerFileModel> selectedFiles; final List<AttachmentModel> selectedFiles;
@override @override
List<Object?> get props => [status, error, customerFiles, selectedFiles]; List<Object?> get props => [status, error, customerFiles, selectedFiles];
@@ -21,8 +21,8 @@ class CustomerFilesState extends Equatable {
CustomerFilesState copyWith({ CustomerFilesState copyWith({
CustomerFilesStatus? status, CustomerFilesStatus? status,
String? error, String? error,
List<CustomerFileModel>? customerFiles, List<AttachmentModel>? customerFiles,
List<CustomerFileModel>? selectedFiles, List<AttachmentModel>? selectedFiles,
}) { }) {
return CustomerFilesState( return CustomerFilesState(
status: status ?? this.status, status: status ?? this.status,

View File

@@ -3,35 +3,34 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
part 'customer_state.dart'; part 'customers_state.dart';
class CustomerCubit extends Cubit<CustomerState> { class CustomersCubit extends Cubit<CustomersState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>(); final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>(); final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
// Variabile per gestire il debounce della ricerca // Variabile per gestire il debounce della ricerca
Timer? _searchDebounce; Timer? _searchDebounce;
CustomerCubit() : super(const CustomerState()); CustomersCubit() : super(const CustomersState());
// --- LETTURA --- // --- LETTURA ---
Future<void> loadCustomers() async { Future<void> loadCustomers() async {
emit(state.copyWith(status: CustomerStatus.loading)); emit(state.copyWith(status: CustomersStatus.loading));
try { try {
final customers = await _repository.getCustomers( final customers = await _repository.getCustomers(
_sessionCubit.state.company!.id!, _sessionCubit.state.company!.id!,
); );
emit( emit(
state.copyWith(status: CustomerStatus.success, customers: customers), state.copyWith(status: CustomersStatus.success, customers: customers),
); );
} catch (e) { } catch (e) {
emit( emit(
state.copyWith( state.copyWith(
status: CustomerStatus.failure, status: CustomersStatus.failure,
errorMessage: e.toString(), errorMessage: e.toString(),
), ),
); );
@@ -40,7 +39,7 @@ class CustomerCubit extends Cubit<CustomerState> {
// --- CREAZIONE --- // --- CREAZIONE ---
Future<void> createCustomer(CustomerModel customer) async { Future<void> createCustomer(CustomerModel customer) async {
emit(state.copyWith(status: CustomerStatus.loading)); emit(state.copyWith(status: CustomersStatus.loading));
try { try {
final newCustomer = await _repository.saveCustomer(customer); final newCustomer = await _repository.saveCustomer(customer);
@@ -50,7 +49,7 @@ class CustomerCubit extends Cubit<CustomerState> {
emit( emit(
state.copyWith( state.copyWith(
status: CustomerStatus.success, status: CustomersStatus.success,
customers: updatedList, customers: updatedList,
lastCreatedCustomer: newCustomer, lastCreatedCustomer: newCustomer,
), ),
@@ -58,7 +57,7 @@ class CustomerCubit extends Cubit<CustomerState> {
} catch (e) { } catch (e) {
emit( emit(
state.copyWith( state.copyWith(
status: CustomerStatus.failure, status: CustomersStatus.failure,
errorMessage: e.toString(), errorMessage: e.toString(),
), ),
); );
@@ -67,7 +66,7 @@ class CustomerCubit extends Cubit<CustomerState> {
// --- AGGIORNAMENTO --- // --- AGGIORNAMENTO ---
Future<void> updateCustomer(CustomerModel customer) async { Future<void> updateCustomer(CustomerModel customer) async {
emit(state.copyWith(status: CustomerStatus.loading)); emit(state.copyWith(status: CustomersStatus.loading));
try { try {
final updatedCustomer = await _repository.updateCustomer(customer); final updatedCustomer = await _repository.updateCustomer(customer);
@@ -80,7 +79,7 @@ class CustomerCubit extends Cubit<CustomerState> {
emit( emit(
state.copyWith( state.copyWith(
status: CustomerStatus.success, status: CustomersStatus.success,
customers: updatedList, customers: updatedList,
lastCreatedCustomer: lastCreatedCustomer:
updatedCustomer, // Utile se modifichi un cliente appena creato updatedCustomer, // Utile se modifichi un cliente appena creato
@@ -89,7 +88,7 @@ class CustomerCubit extends Cubit<CustomerState> {
} catch (e) { } catch (e) {
emit( emit(
state.copyWith( state.copyWith(
status: CustomerStatus.failure, status: CustomersStatus.failure,
errorMessage: e.toString(), errorMessage: e.toString(),
), ),
); );
@@ -116,12 +115,12 @@ class CustomerCubit extends Cubit<CustomerState> {
query, query,
); );
emit( emit(
state.copyWith(status: CustomerStatus.success, customers: results), state.copyWith(status: CustomersStatus.success, customers: results),
); );
} catch (e) { } catch (e) {
emit( emit(
state.copyWith( state.copyWith(
status: CustomerStatus.failure, status: CustomersStatus.failure,
errorMessage: e.toString(), errorMessage: e.toString(),
), ),
); );
@@ -135,8 +134,8 @@ class CustomerCubit extends Cubit<CustomerState> {
String? email, String? email,
}) async { }) async {
final newCustomer = CustomerModel( final newCustomer = CustomerModel(
nome: name, name: name,
telefono: phone ?? '', phoneNumber: phone ?? '',
email: email ?? '', email: email ?? '',
companyId: _sessionCubit.state.company!.id!, companyId: _sessionCubit.state.company!.id!,
note: '', note: '',

View File

@@ -1,6 +1,6 @@
part of 'customer_cubit.dart'; part of 'customers_cubit.dart';
enum CustomerStatus { enum CustomersStatus {
initial, initial,
loading, loading,
filesLoading, filesLoading,
@@ -9,34 +9,30 @@ enum CustomerStatus {
failure, failure,
} }
class CustomerState extends Equatable { class CustomersState extends Equatable {
final CustomerStatus status; final CustomersStatus status;
final List<CustomerModel> customers; final List<CustomerModel> customers;
final CustomerModel? lastCreatedCustomer; final CustomerModel? lastCreatedCustomer;
final String? errorMessage; final String? errorMessage;
final List<CustomerFileModel> customerFiles;
const CustomerState({ const CustomersState({
this.status = CustomerStatus.initial, this.status = CustomersStatus.initial,
this.customers = const [], this.customers = const [],
this.lastCreatedCustomer, this.lastCreatedCustomer,
this.errorMessage, this.errorMessage,
this.customerFiles = const [],
}); });
CustomerState copyWith({ CustomersState copyWith({
CustomerStatus? status, CustomersStatus? status,
List<CustomerModel>? customers, List<CustomerModel>? customers,
CustomerModel? lastCreatedCustomer, CustomerModel? lastCreatedCustomer,
String? errorMessage, String? errorMessage,
List<CustomerFileModel>? customerFiles,
}) { }) {
return CustomerState( return CustomersState(
status: status ?? this.status, status: status ?? this.status,
customers: customers ?? this.customers, customers: customers ?? this.customers,
lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer, lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
customerFiles: customerFiles ?? this.customerFiles,
); );
} }
@@ -46,6 +42,5 @@ class CustomerState extends Equatable {
customers, customers,
lastCreatedCustomer, lastCreatedCustomer,
errorMessage, errorMessage,
customerFiles,
]; ];
} }

View File

@@ -1,8 +1,7 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/customer_model.dart'; import '../models/customer_model.dart';
@@ -21,7 +20,7 @@ class CustomerRepository {
.single(); .single();
return CustomerModel.fromMap(response); return CustomerModel.fromMap(response);
} catch (e) { } catch (e) {
throw 'Errore durante il salvataggio del cliente: $e'; throw '$e';
} }
} }
@@ -35,7 +34,7 @@ class CustomerRepository {
.single(); .single();
return CustomerModel.fromMap(response); return CustomerModel.fromMap(response);
} catch (e) { } catch (e) {
throw 'Errore durante la modifica del cliente: $e'; throw '$e';
} }
} }
@@ -46,15 +45,15 @@ class CustomerRepository {
.from('customer') .from('customer')
.select(''' .select('''
*, *,
customer_file(*) attachment(*)
''') ''')
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('is_active', true) .eq('is_active', true)
.order('nome'); .order('name');
return (response as List).map((c) => CustomerModel.fromMap(c)).toList(); return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
} catch (e) { } catch (e) {
throw 'Errore nel recupero clienti'; throw '$e';
} }
} }
@@ -68,7 +67,7 @@ class CustomerRepository {
.from('customer') .from('customer')
.select() .select()
.eq('company_id', companyId) .eq('company_id', companyId)
.or('nome.ilike.%$query%,telefono.ilike.%$query%') .or('name.ilike.%$query%,phone_number.ilike.%$query%')
.limit(10); .limit(10);
return (response as List).map((c) => CustomerModel.fromMap(c)).toList(); return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
@@ -78,36 +77,34 @@ class CustomerRepository {
} }
/// Ascolta in tempo reale i file caricati per un cliente /// Ascolta in tempo reale i file caricati per un cliente
Stream<List<CustomerFileModel>> getCustomerFilesStream(String customerId) { Stream<List<AttachmentModel>> getCustomerFilesStream(String customerId) {
return _supabase return _supabase
.from('customer_file') .from('attachment')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.eq('customer_id', customerId) .eq('customer_id', customerId)
.order('created_at', ascending: false) .order('created_at', ascending: false)
.map( .map(
(listOfMaps) => (listOfMaps) =>
listOfMaps.map((map) => CustomerFileModel.fromMap(map)).toList(), listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
); );
} }
/// Recupera i file di un cliente specifico /// Recupera i file di un cliente specifico
Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async { Future<List<AttachmentModel>> getCustomerFiles(String customerId) async {
try { try {
final response = await _supabase final response = await _supabase
.from('customer_file') .from('attachment')
.select() .select()
.eq('customer_id', customerId); .eq('customer_id', customerId);
return (response as List) return (response as List).map((f) => AttachmentModel.fromMap(f)).toList();
.map((f) => CustomerFileModel.fromMap(f))
.toList();
} catch (e) { } catch (e) {
throw 'Errore recupero file: $e'; throw '$e';
} }
} }
/// Carica un file e salva il riferimento nel database /// Carica un file e salva il riferimento nel database
Future<CustomerFileModel> uploadAndRegisterFile({ Future<AttachmentModel> uploadAndRegisterFile({
required String customerId, required String customerId,
required PlatformFile pickedFile, required PlatformFile pickedFile,
}) async { }) async {
@@ -118,7 +115,8 @@ class CustomerRepository {
final storagePath = final storagePath =
'$companyId/customers/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName'; '$companyId/customers/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
final int fileSize = pickedFile.size; final int fileSize = pickedFile.size;
final fileToSave = CustomerFileModel( final fileToSave = AttachmentModel(
companyId: companyId,
customerId: customerId, customerId: customerId,
name: cleanFileName.fileNameWithoutExtension(), name: cleanFileName.fileNameWithoutExtension(),
extension: cleanFileName.fileExtension(), extension: cleanFileName.fileExtension(),
@@ -131,7 +129,7 @@ class CustomerRepository {
try { try {
// Usiamo bytes invece del path per massima compatibilità // Usiamo bytes invece del path per massima compatibilità
if (pickedFile.bytes == null && pickedFile.path == null) { if (pickedFile.bytes == null && pickedFile.path == null) {
throw 'Impossibile leggere il contenuto del file'; throw 'File read error';
} }
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes // Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
@@ -146,54 +144,51 @@ class CustomerRepository {
} }
final response = await _supabase final response = await _supabase
.from('customer_file') .from('attachment')
.insert(fileToSave.toMap()) .insert(fileToSave.toMap())
.select() .select()
.single(); .single();
return CustomerFileModel.fromMap(response); return AttachmentModel.fromMap(response);
} catch (e) { } catch (e) {
throw 'Errore durante l\'upload: $e'; throw '$e';
} }
} }
Future<void> saveFileReference(CustomerFileModel file) async { Future<void> saveFileReference(AttachmentModel file) async {
await _supabase.from('customer_file').upsert(file.toMap()); await _supabase.from('attachment').upsert(file.toMap());
} }
/// Aggiorna la lista degli URL nel database Future<void> deleteDocuments(List<AttachmentModel> files) async {
Future<void> updateCustomerDocuments(int id, List<String> urls) async {
await _supabase
.from('customer')
.update({'document_urls': urls})
.eq('id', id);
}
Future<void> deleteDocuments(List<CustomerFileModel> files) async {
if (files.isEmpty) return; if (files.isEmpty) return;
// 1. Prepariamo le liste di ID e di Percorsi // 1. Prepariamo le liste di ID e di Percorsi
final List<String> idsToDelete = files.map((f) => f.id!).toList(); final List<String> idsToDelete = [];
final List<String> storagePaths = files.map((f) => f.storagePath).toList(); final List<String> storagePathsToDelete = [];
final List<String> idsToEdit = [];
for (var file in files) {
if (file.operationId == null) {
idsToDelete.add(file.id!);
storagePathsToDelete.add(file.storagePath!);
} else {
idsToEdit.add(file.id!);
}
}
try { try {
// 2. Cancellazione MASSIVA dal DB (una sola chiamata invece di un ciclo!) if (idsToDelete.isNotEmpty) {
// .in_ dice: "cancella tutti i record il cui ID è contenuto in questa lista" await _supabase.from('attachment').delete().inFilter('id', idsToDelete);
await _supabase
.from('customer_file')
.delete()
.inFilter('id', idsToDelete);
// 3. Cancellazione MASSIVA dallo Storage // 3. Cancellazione MASSIVA dallo Storage
await _supabase.storage.from('documents').remove(storagePaths); await _supabase.storage.from('documents').remove(storagePathsToDelete);
}
debugPrint("Eliminati con successo ${files.length} file."); if (idsToEdit.isNotEmpty) {
await _supabase
.from('attachment')
.update({'customer_id': null})
.inFilter('id', idsToEdit);
}
} on PostgrestException catch (e) { } on PostgrestException catch (e) {
debugPrint("Errore DB: ${e.message}"); throw e.message;
throw 'Errore database: ${e.message}';
} catch (e) { } catch (e) {
debugPrint("Errore generico: $e"); throw '$e';
throw 'Errore durante l\'eliminazione dei file: $e';
} }
} }
} }

View File

@@ -1,91 +0,0 @@
import 'package:equatable/equatable.dart';
class CustomerFileModel extends Equatable {
final String? id;
final String customerId; // Riferimento UUID
final String name;
final String storagePath;
final String extension;
final DateTime? createdAt;
final int fileSize;
const CustomerFileModel({
this.id,
required this.customerId,
required this.name,
required this.storagePath,
required this.extension,
this.createdAt,
required this.fileSize,
});
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
String get sizeFormatted {
if (fileSize <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"];
var i = (fileSize.toString().length - 1) ~/ 3;
if (i >= suffixes.length) i = suffixes.length - 1;
double num = fileSize / (1 << (i * 10));
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
}
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
CustomerFileModel copyWith({
String? id,
String? customerId,
String? name,
String? storagePath,
String? extension,
DateTime? createdAt,
int? fileSize,
}) {
return CustomerFileModel(
id: id ?? this.id,
customerId: customerId ?? this.customerId,
name: name ?? this.name,
storagePath: storagePath ?? this.storagePath,
extension: extension ?? this.extension,
createdAt: createdAt ?? this.createdAt,
fileSize: fileSize ?? this.fileSize,
);
}
factory CustomerFileModel.fromMap(Map<String, dynamic> map) {
return CustomerFileModel(
id: map['id'] as String,
customerId: map['customer_id'],
name: map['name'],
storagePath: map['storage_path'],
extension: map['extension'] ?? '',
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
fileSize: map['file_size'] is int
? map['file_size']
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'customer_id': customerId,
'name': name,
'storage_path': storagePath,
'extension': extension,
'file_size': fileSize,
};
}
@override
List<Object?> get props => [
id,
customerId,
name,
storagePath,
extension,
createdAt,
fileSize,
];
}

View File

@@ -1,74 +1,74 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:flux/features/attachments/models/attachment_model.dart';
class CustomerModel extends Equatable { class CustomerModel extends Equatable {
final String? id; // Bigint in SQL final String? id; // Bigint in SQL
final DateTime? createdAt; final DateTime? createdAt;
final String nome; final String name;
final String telefono; final String phoneNumber;
final String email; final String email;
final String note; final String note;
final DateTime? dataUltimoContatto; final DateTime? lastContactDate;
final bool nonDisturbare; final bool doNotDisturb;
final String companyId; // UUID final String companyId; // UUID
final bool isActive; final bool isActive;
final List<CustomerFileModel> files; final List<AttachmentModel> attachments;
const CustomerModel({ const CustomerModel({
this.id, this.id,
this.createdAt, this.createdAt,
required this.nome, required this.name,
required this.telefono, required this.phoneNumber,
required this.email, required this.email,
required this.note, required this.note,
this.dataUltimoContatto, this.lastContactDate,
this.nonDisturbare = false, this.doNotDisturb = false,
required this.companyId, required this.companyId,
this.isActive = true, this.isActive = true,
this.files = const [], this.attachments = const [],
}); });
@override @override
List<Object?> get props => [ List<Object?> get props => [
id, id,
createdAt, createdAt,
nome, name,
telefono, phoneNumber,
email, email,
note, note,
dataUltimoContatto, lastContactDate,
nonDisturbare, doNotDisturb,
companyId, companyId,
isActive, isActive,
files, attachments,
]; ];
CustomerModel copyWith({ CustomerModel copyWith({
String? id, String? id,
DateTime? createdAt, DateTime? createdAt,
String? nome, String? name,
String? telefono, String? phoneNumber,
String? email, String? email,
String? note, String? note,
DateTime? dataUltimoContatto, DateTime? lastContactDate,
bool? nonDisturbare, bool? doNotDisturb,
String? companyId, String? companyId,
bool? isActive, bool? isActive,
List<CustomerFileModel>? files, List<AttachmentModel>? attachments,
}) { }) {
return CustomerModel( return CustomerModel(
id: id ?? this.id, id: id ?? this.id,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
nome: nome ?? this.nome, name: name ?? this.name,
telefono: telefono ?? this.telefono, phoneNumber: phoneNumber ?? this.phoneNumber,
email: email ?? this.email, email: email ?? this.email,
note: note ?? this.note, note: note ?? this.note,
dataUltimoContatto: dataUltimoContatto ?? this.dataUltimoContatto, lastContactDate: lastContactDate ?? this.lastContactDate,
nonDisturbare: nonDisturbare ?? this.nonDisturbare, doNotDisturb: doNotDisturb ?? this.doNotDisturb,
companyId: companyId ?? this.companyId, companyId: companyId ?? this.companyId,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
files: files ?? this.files, attachments: attachments ?? this.attachments,
); );
} }
@@ -78,19 +78,19 @@ class CustomerModel extends Equatable {
createdAt: map['created_at'] != null createdAt: map['created_at'] != null
? DateTime.parse(map['created_at']) ? DateTime.parse(map['created_at'])
: null, : null,
nome: (map['nome'] as String).myFormat(), name: (map['name'] as String).myFormat(),
telefono: map['telefono'], phoneNumber: map['phone_number'],
email: map['email'], email: map['email'],
note: map['note'] ?? '', note: map['note'] ?? '',
dataUltimoContatto: map['data_ultimo_contatto'] != null lastContactDate: map['last_contact_date'] != null
? DateTime.parse(map['data_ultimo_contatto']) ? DateTime.parse(map['last_contact_date'])
: null, : null,
nonDisturbare: map['non_disturbare'] ?? false, doNotDisturb: map['do_not_disturb'] ?? false,
companyId: map['company_id'] as String, companyId: map['company_id'] as String,
isActive: map['is_active'] ?? true, isActive: map['is_active'] ?? true,
files: attachments:
(map['customer_file'] as List?) (map['attachment'] as List?)
?.map((x) => CustomerFileModel.fromMap(x)) ?.map((x) => AttachmentModel.fromMap(x))
.toList() ?? .toList() ??
const [], const [],
); );
@@ -99,13 +99,13 @@ class CustomerModel extends Equatable {
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
if (id != null) 'id': id, if (id != null) 'id': id,
'nome': nome.toLowerCase().trim(), 'name': name.toLowerCase().trim(),
'telefono': telefono, 'phone_number': phoneNumber,
'email': email.toLowerCase().trim(), 'email': email.toLowerCase().trim(),
'note': note, 'note': note,
if (dataUltimoContatto != null) if (lastContactDate != null)
'data_ultimo_contatto': dataUltimoContatto!.toIso8601String(), 'last_contact_date': lastContactDate!.toIso8601String(),
'non_disturbare': nonDisturbare, 'do_not_disturb': doNotDisturb,
'company_id': companyId, 'company_id': companyId,
'is_active': isActive, 'is_active': isActive,
}; };

View File

@@ -6,9 +6,9 @@ import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/widgets/image_viewer_widget.dart'; import 'package:flux/core/widgets/image_viewer_widget.dart';
import 'package:flux/core/widgets/pdf_viewer_widget.dart'; import 'package:flux/core/widgets/pdf_viewer_widget.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart'; import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/attachments/models/attachment_model.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/models/customer_file_model.dart';
class CustomerDetailScreen extends StatefulWidget { class CustomerDetailScreen extends StatefulWidget {
final CustomerModel customer; final CustomerModel customer;
@@ -62,7 +62,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
backgroundColor: context.background, backgroundColor: context.background,
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
widget.customer.nome, widget.customer.name,
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
backgroundColor: context.background, backgroundColor: context.background,
@@ -103,7 +103,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_infoTile(Icons.phone_android, "Telefono", widget.customer.telefono), _infoTile(Icons.phone_android, "Telefono", widget.customer.phoneNumber),
_infoTile( _infoTile(
Icons.email_outlined, Icons.email_outlined,
"Email", "Email",
@@ -117,7 +117,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
: widget.customer.note, : widget.customer.note,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
if (widget.customer.nonDisturbare) if (widget.customer.doNotDisturb)
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -191,8 +191,8 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
context: context, context: context,
builder: (context) => QrUploadDialog( builder: (context) => QrUploadDialog(
deepLinkUrl: deepLinkUrl:
'fluxapp://customer/${widget.customer.id}/upload?name=${Uri.encodeComponent(widget.customer.nome)}', 'fluxapp://customer/${widget.customer.id}/upload?name=${Uri.encodeComponent(widget.customer.name)}',
title: 'Scatta per ${widget.customer.nome}', title: 'Scatta per ${widget.customer.name}',
), ),
); );
}, },
@@ -262,12 +262,12 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
void _showDeleteConfirmationDialog({ void _showDeleteConfirmationDialog({
required BuildContext context, required BuildContext context,
required List<CustomerFileModel> files, required List<AttachmentModel> files,
}) {} }) {}
} }
class _FileCard extends StatelessWidget { class _FileCard extends StatelessWidget {
final CustomerFileModel file; final AttachmentModel file;
final CustomerFilesState state; final CustomerFilesState state;
const _FileCard({required this.file, required this.state}); const _FileCard({required this.file, required this.state});
@@ -334,7 +334,7 @@ class _FileCard extends StatelessWidget {
} }
} }
void _handleDoubleClickOnFile(BuildContext context, CustomerFileModel file) { void _handleDoubleClickOnFile(BuildContext context, AttachmentModel file) {
showDialog( showDialog(
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,

View File

@@ -30,15 +30,15 @@ class _CustomerFormState extends State<CustomerForm> {
void initState() { void initState() {
super.initState(); super.initState();
// Se widget.customer è null, i campi saranno vuoti // Se widget.customer è null, i campi saranno vuoti
_nomeController = TextEditingController(text: widget.customer?.nome ?? ''); _nomeController = TextEditingController(text: widget.customer?.name ?? '');
_telefonoController = TextEditingController( _telefonoController = TextEditingController(
text: widget.customer?.telefono ?? '', text: widget.customer?.phoneNumber ?? '',
); );
_emailController = TextEditingController( _emailController = TextEditingController(
text: widget.customer?.email ?? '', text: widget.customer?.email ?? '',
); );
_noteController = TextEditingController(text: widget.customer?.note ?? ''); _noteController = TextEditingController(text: widget.customer?.note ?? '');
_nonDisturbare = widget.customer?.nonDisturbare ?? false; _nonDisturbare = widget.customer?.doNotDisturb ?? false;
} }
@override @override
@@ -56,19 +56,19 @@ class _CustomerFormState extends State<CustomerForm> {
// o creandone uno da zero, preservando l'ID in caso di modifica. // o creandone uno da zero, preservando l'ID in caso di modifica.
final updatedCustomer = final updatedCustomer =
widget.customer?.copyWith( widget.customer?.copyWith(
nome: _nomeController.text.trim(), name: _nomeController.text.trim(),
telefono: _telefonoController.text.trim(), phoneNumber: _telefonoController.text.trim(),
email: _emailController.text.trim(), email: _emailController.text.trim(),
note: _noteController.text.trim(), note: _noteController.text.trim(),
nonDisturbare: _nonDisturbare, doNotDisturb: _nonDisturbare,
) ?? ) ??
CustomerModel( CustomerModel(
// Caso nuovo cliente // Caso nuovo cliente
nome: _nomeController.text.trim(), name: _nomeController.text.trim(),
telefono: _telefonoController.text.trim(), phoneNumber: _telefonoController.text.trim(),
email: _emailController.text.trim(), email: _emailController.text.trim(),
note: _noteController.text.trim(), note: _noteController.text.trim(),
nonDisturbare: _nonDisturbare, doNotDisturb: _nonDisturbare,
companyId: '', // Verrà iniettato dal Bloc o dal chiamante companyId: '', // Verrà iniettato dal Bloc o dal chiamante
); );

View File

@@ -1,202 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/customers/blocs/customer_cubit.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
class CustomerSearchSheet extends StatefulWidget {
const CustomerSearchSheet({super.key});
@override
State<CustomerSearchSheet> createState() => _CustomerSearchSheetState();
}
class _CustomerSearchSheetState extends State<CustomerSearchSheet> {
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
context.read<CustomerCubit>().loadCustomers();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String query) {
context.read<CustomerCubit>().searchCustomers(query);
}
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER ---
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Trova Cliente",
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
tooltip: "Chiudi",
),
],
),
const SizedBox(height: 16),
// --- BARRA DI RICERCA ---
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: "Cerca per nome, cognome o CF...",
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged("");
},
),
),
onChanged: _onSearchChanged,
),
const SizedBox(height: 16),
// --- TASTO NUOVO CLIENTE ---
SizedBox(
width: double.infinity,
child: IconButton(
icon: const Icon(Icons.person_add),
onPressed: () async {
final servicesCubit = context.read<ServicesCubit>();
// Apriamo la dialog passando la query attuale
final CustomerModel? nuovoCliente = await showDialog(
context: context,
builder: (context) => QuickCustomerDialog(
initialQuery: _searchController.text,
),
);
if (nuovoCliente != null) {
servicesCubit.updateField(
customerId: nuovoCliente.id,
customerDisplayName: nuovoCliente.nome,
);
setState(() {
_searchController.clear();
});
}
},
),
),
const SizedBox(height: 24),
// --- LISTA RISULTATI CON BLOC BUILDER ---
const Text(
"Risultati",
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey),
),
const SizedBox(height: 8),
Expanded(
// AGGANCIO AL CUBIT REALE
child: BlocBuilder<CustomerCubit, CustomerState>(
builder: (context, state) {
// 1. Stato di caricamento
if (state.status == CustomerStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
// 2. Nessun risultato trovato
if (state.customers.isEmpty) {
return const Center(
child: Text(
"Nessun cliente trovato.\nProva a cambiare i termini di ricerca.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
);
}
// 3. Mostriamo la lista vera
return ListView.separated(
itemCount: state.customers.length,
separatorBuilder: (context, index) =>
const Divider(height: 1),
itemBuilder: (context, index) {
final customer = state.customers[index];
// Assumo che il tuo CustomerModel abbia le proprietà name e surname.
// Adatta queste variabili al tuo modello reale!
final displayName = customer.nome.trim();
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimaryContainer,
// Mostra l'iniziale
child: Text(
displayName.isNotEmpty
? displayName[0].toUpperCase()
: "?",
),
),
title: Text(
displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(customer.email),
trailing: const Icon(
Icons.check_circle_outline,
color: Colors.grey,
),
onTap: () {
// Salviamo l'ID e il nome formattato nel form dei servizi
context.read<ServicesCubit>().updateField(
customerId: customer.id,
customerDisplayName: displayName,
);
// Chiudiamo la modale
Navigator.pop(context);
},
);
},
);
},
),
),
],
),
),
);
}
}

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/customers/blocs/customer_cubit.dart'; import 'package:flux/features/customers/blocs/customers_cubit.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_form.dart'; import 'package:flux/features/customers/ui/customer_form.dart';
import 'package:flux/temp/migration_tools.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class CustomersContent extends StatefulWidget { class CustomersContent extends StatefulWidget {
@@ -26,14 +28,14 @@ class _CustomersContentState extends State<CustomersContent> {
void _loadInitialCustomers() { void _loadInitialCustomers() {
final companyId = context.read<SessionCubit>().state.company?.id; final companyId = context.read<SessionCubit>().state.company?.id;
if (companyId != null) { if (companyId != null) {
context.read<CustomerCubit>().loadCustomers(); context.read<CustomersCubit>().loadCustomers();
} }
} }
void _onSearch(String query) { void _onSearch(String query) {
final companyId = context.read<SessionCubit>().state.company?.id; final companyId = context.read<SessionCubit>().state.company?.id;
if (companyId != null) { if (companyId != null) {
context.read<CustomerCubit>().searchCustomers(query); context.read<CustomersCubit>().searchCustomers(query);
} }
} }
@@ -84,11 +86,47 @@ class _CustomersContentState extends State<CustomersContent> {
), ),
), ),
//TODO cancella quando import finito
ElevatedButton(
onPressed: () async {
try {
// 1. Mostra un loading (opzionale ma utile)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Caricamento JSON in corso...')),
);
// 2. Legge tutto il file come stringa
final String jsonString = await rootBundle.loadString(
'assets/schedeRiparazione-1778021345.json',
);
// 3. Lancia lo script (sostituisci l'UUID con l'ID della tua azienda su Supabase)
await TicketMigrationScript().runMigration(jsonString);
// 4. Successo!
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Migrazione Completata! Guarda i log.'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Errore: $e')));
}
}
},
child: const Text('migra clienti'),
),
// LISTA CLIENTI // LISTA CLIENTI
Expanded( Expanded(
child: BlocBuilder<CustomerCubit, CustomerState>( child: BlocBuilder<CustomersCubit, CustomersState>(
builder: (context, state) { builder: (context, state) {
if (state.status == CustomerStatus.loading && if (state.status == CustomersStatus.loading &&
state.customers.isEmpty) { state.customers.isEmpty) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -166,7 +204,7 @@ class _CustomerTile extends StatelessWidget {
radius: 24, radius: 24,
backgroundColor: context.accent.withValues(alpha: 0.1), backgroundColor: context.accent.withValues(alpha: 0.1),
child: Text( child: Text(
customer.nome.isNotEmpty ? customer.nome[0].toUpperCase() : '?', customer.name.isNotEmpty ? customer.name[0].toUpperCase() : '?',
style: TextStyle( style: TextStyle(
color: context.accent, color: context.accent,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -174,7 +212,7 @@ class _CustomerTile extends StatelessWidget {
), ),
), ),
title: Text( title: Text(
customer.nome, customer.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
), ),
subtitle: Padding( subtitle: Padding(
@@ -184,7 +222,7 @@ class _CustomerTile extends StatelessWidget {
Icon(Icons.phone_android, size: 14, color: context.secondaryText), Icon(Icons.phone_android, size: 14, color: context.secondaryText),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
customer.telefono, customer.phoneNumber,
style: TextStyle(color: context.secondaryText), style: TextStyle(color: context.secondaryText),
), ),
if (customer.email.isNotEmpty) ...[ if (customer.email.isNotEmpty) ...[
@@ -196,11 +234,11 @@ class _CustomerTile extends StatelessWidget {
style: TextStyle(color: context.secondaryText), style: TextStyle(color: context.secondaryText),
), ),
], ],
if (customer.files.isNotEmpty) ...[ if (customer.attachments.isNotEmpty) ...[
Text(' - ', style: TextStyle(color: context.secondaryText)), Text(' - ', style: TextStyle(color: context.secondaryText)),
Icon(Icons.attach_file, size: 14, color: context.accent), Icon(Icons.attach_file, size: 14, color: context.accent),
Text( Text(
'${customer.files.length} doc', '${customer.attachments.length} doc',
style: TextStyle( style: TextStyle(
color: context.accent, color: context.accent,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -242,12 +280,12 @@ void openCustomerForm({
if (customer == null) { if (customer == null) {
// CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create // CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create
context.read<CustomerCubit>().createCustomer( context.read<CustomersCubit>().createCustomer(
customerFromForm.copyWith(companyId: companyId), customerFromForm.copyWith(companyId: companyId),
); );
} else { } else {
// CASO MODIFICA: L'ID e il companyId sono già nel modello // CASO MODIFICA: L'ID e il companyId sono già nel modello
context.read<CustomerCubit>().updateCustomer(customerFromForm); context.read<CustomersCubit>().updateCustomer(customerFromForm);
} }
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
}, },

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/customers/blocs/customer_cubit.dart'; import 'package:flux/features/customers/blocs/customers_cubit.dart';
class QuickCustomerDialog extends StatefulWidget { class QuickCustomerDialog extends StatefulWidget {
final String initialQuery; final String initialQuery;
@@ -42,7 +42,9 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
// Chiamata al Cubit (aggiorna i parametri in base a come li hai definiti) // Chiamata al Cubit (aggiorna i parametri in base a come li hai definiti)
final newCustomer = await context.read<CustomerCubit>().quickCreateCustomer( final newCustomer = await context
.read<CustomersCubit>()
.quickCreateCustomer(
name: _nameCtrl.text.trim(), name: _nameCtrl.text.trim(),
phone: _phoneCtrl.text.trim(), phone: _phoneCtrl.text.trim(),
// Aggiungi questi se li hai inseriti nel tuo CustomerCubit: // Aggiungi questi se li hai inseriti nel tuo CustomerCubit:

View File

@@ -0,0 +1,66 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:get_it/get_it.dart';
part '../../latest_store_operations/bloc/latest_store_operations_events.dart';
part '../../latest_store_operations/bloc/latest_store_operations_state.dart';
class LatestStoreOperationsBloc
extends Bloc<LatestStoreOperationsEvent, LatestStoreOperationsState> {
final _repository = GetIt.I.get<OperationsRepository>();
LatestStoreOperationsBloc()
: super(
const LatestStoreOperationsState(
status: LatestStoreOperationsStatus.initial,
),
) {
on<InitLastStoreOperationsEvent>((event, emit) async {
emit(state.copyWith(status: LatestStoreOperationsStatus.loading));
try {
// 1. Creiamo uno stream "intermedio" che idrata i dati
final hydratedStream = _repository
.getLastStoreOperationsStream(storeId: event.storeId, limit: 5)
.asyncMap((List<OperationModel> rawOperations) async {
// Questo gira ad ogni "scatto" dello stream di Supabase
List<OperationModel> fullyHydratedOperations = [];
for (OperationModel operation in rawOperations) {
// Peschiamo i dati completi (incluso il cliente)
OperationModel fullOperation = await _repository
.fetchOperationById(operation.id!);
fullyHydratedOperations.add(fullOperation);
}
// Passiamo la lista completa allo step successivo
return fullyHydratedOperations;
});
// 2. Ora passiamo lo stream idratato all'emit.forEach
await emit.forEach(
hydratedStream, // Usiamo lo stream modificato!
onData: (List<OperationModel> fullyHydratedOperations) {
// Qui ora è tutto sincrono e bellissimo
return state.copyWith(
operations: fullyHydratedOperations,
status: LatestStoreOperationsStatus.success,
);
},
onError: (error, stackTrace) => state.copyWith(
status: LatestStoreOperationsStatus.failure,
error: error.toString(),
),
);
} catch (e) {
emit(
state.copyWith(
status: LatestStoreOperationsStatus.failure,
error: e.toString(),
),
);
}
});
}
}

View File

@@ -0,0 +1,17 @@
part of 'latest_store_operations_bloc.dart';
sealed class LatestStoreOperationsEvent extends Equatable {
const LatestStoreOperationsEvent();
@override
List<Object> get props => [];
}
class InitLastStoreOperationsEvent extends LatestStoreOperationsEvent {
final String storeId;
const InitLastStoreOperationsEvent(this.storeId);
@override
List<Object> get props => [storeId];
}

View File

@@ -0,0 +1,30 @@
part of 'latest_store_operations_bloc.dart';
enum LatestStoreOperationsStatus { initial, loading, success, failure }
class LatestStoreOperationsState extends Equatable {
final LatestStoreOperationsStatus status;
final String? error;
final List<OperationModel> operations;
const LatestStoreOperationsState({
required this.status,
this.error,
this.operations = const [],
});
@override
List<Object?> get props => [status, error, operations];
LatestStoreOperationsState copyWith({
LatestStoreOperationsStatus? status,
String? error,
List<OperationModel>? operations,
}) {
return LatestStoreOperationsState(
status: status ?? this.status,
error: error,
operations: operations ?? this.operations,
);
}
}

View File

@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart';
import 'package:go_router/go_router.dart';
class LatestStoreOperationsCard extends StatelessWidget {
const LatestStoreOperationsCard({super.key});
@override
Widget build(BuildContext context) {
final currentStoreId = context.read<SessionCubit>().state.currentStore?.id;
return BlocProvider(
// 1. Creiamo il Bloc e facciamo partire subito la query
create: (context) =>
LatestStoreOperationsBloc()
..add(InitLastStoreOperationsEvent(currentStoreId ?? '')),
child: BlocListener<SessionCubit, SessionState>(
// 2. MAGIA: Se l'utente cambia negozio dalla barra in alto, riavviamo lo stream!
listenWhen: (previous, current) =>
previous.currentStore?.id != current.currentStore?.id,
listener: (context, state) {
if (state.currentStore?.id != null) {
context.read<LatestStoreOperationsBloc>().add(
InitLastStoreOperationsEvent(state.currentStore!.id!),
);
}
},
child: _LatestOperationsCardContent(),
),
);
}
}
class _LatestOperationsCardContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const color = Colors.blue;
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: [
// --- HEADER DELLA CARD ---
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.design_services_outlined,
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: TextButton(
onPressed: () => context.push('/operations'),
child: Text(
context.l10n.homeLatestOperations,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
const SizedBox(height: 12),
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
Expanded(
child:
BlocBuilder<
LatestStoreOperationsBloc,
LatestStoreOperationsState
>(
builder: (context, state) {
if (state.status == LatestStoreOperationsStatus.loading ||
state.status == LatestStoreOperationsStatus.initial) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == LatestStoreOperationsStatus.failure) {
return Center(
child: Text(
"Errore di caricamento",
style: TextStyle(color: theme.colorScheme.error),
),
);
}
if (state.operations.isEmpty) {
return Center(
child: Text(
"Nessun servizio recente.",
style: TextStyle(
color: context.secondaryText,
fontStyle: FontStyle.italic,
),
),
);
}
return ListView.separated(
itemCount: state.operations.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: theme.dividerColor.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final operation = state.operations[index];
return InkWell(
onTap: () => context.push(
'/operation-form',
extra: operation,
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 5,
child: Text(
operation.customerDisplayName ??
'Cliente sconosciuto',
style: TextStyle(
fontWeight: FontWeight.w700,
color: context.primaryText,
),
),
),
Expanded(
flex: 5,
child: Text(
operation.reference,
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
"${operation.createdAt?.day}/${operation.createdAt?.month}",
style: TextStyle(
color: context.secondaryText,
fontSize: 12,
),
),
],
),
),
);
},
);
},
),
),
],
),
),
);
}
}

View File

@@ -1,44 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flux/core/theme/theme.dart';
class DashboardActionCard extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final VoidCallback onTap;
const DashboardActionCard({
super.key,
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)),
],
),
),
);
}
}

View File

@@ -1,75 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/home/ui/dashboard_action_card.dart';
import 'package:flux/features/services/utils/service_actions.dart';
import 'package:go_router/go_router.dart';
class DashboardAdaptiveGrid extends StatelessWidget {
final bool isLargeScreen;
final Function(int)? onTabRequested;
const DashboardAdaptiveGrid({
super.key,
this.isLargeScreen = false,
this.onTabRequested,
});
@override
Widget build(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: [
DashboardActionCard(
label: 'Nuova Op',
icon: Icons.add_task,
color: context.accent,
onTap: () => startNewService(context),
),
DashboardActionCard(
label: 'Clienti',
icon: Icons.people,
color: Colors.orange,
onTap: () => onTabRequested?.call(1),
),
DashboardActionCard(
label: 'Prodotti',
icon: Icons
.phone_android_outlined, // Icona "comoda" e professionale
color: context
.accent, // O un colore a tua scelta, magari Indigo o Blue
onTap: () => context.push(
'/products',
), // Apre la schermata sopra la Dashboard
),
DashboardActionCard(
label: 'Campagne',
icon: Icons.campaign,
color: Colors.purple,
onTap: () {},
),
DashboardActionCard(
label: 'Report',
icon: Icons.analytics,
color: Colors.teal,
onTap: () {},
),
],
);
},
);
}
}

View File

@@ -1,125 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/home/ui/dashboard_adaptive_grid.dart';
class DashboardContent extends StatelessWidget {
final bool isLargeScreen;
final Function(int)? onTabRequested;
const DashboardContent({
super.key,
this.isLargeScreen = false,
this.onTabRequested,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<SessionCubit, SessionState>(
builder: (context, state) {
final store = state.currentStore;
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),
DashboardAdaptiveGrid(
isLargeScreen: isLargeScreen,
onTabRequested: onTabRequested,
),
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 _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})'),
],
),
);
}
}
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,102 +2,96 @@ 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/core/utils/extensions.dart';
import 'package:flux/features/master_data/master_data_hub_content.dart'; import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart';
import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/home/ui/quick_actions_widget.dart';
import 'package:flux/features/services/ui/services_screen.dart'; import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.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: context.l10n.homeExpiringContracts,
icon: Icons.assignment_late_outlined,
color: Colors.orange,
context: context,
),
_buildDashboardWidget(
title: context.l10n.commonStickyNotes,
icon: Icons.sticky_note_2_outlined,
color: Colors.yellow.shade700,
context: context,
),
_buildDashboardWidget(
title: context.l10n.homeMyTasks,
icon: Icons.check_box_outlined,
color: Colors.green,
context: context,
),
LatestStoreOperationsCard(),
_buildDashboardWidget(
title: context.l10n.homeLatestOperationTickets,
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 +99,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(
context.l10n.homeWelcomeBack(user?.name ?? "Utente"),
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?.name ?? context.l10n.homeNoStoreFound,
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: context.l10n.commonOperation,
color: Colors.blue,
onTap: () {
// Entriamo nel form! Nessun parametro extra = Nuovo Servizio
context.push('/operation-form');
},
),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( QuickActionButton(
GetIt.I.get<SessionCubit>().state.company?.ragioneSociale ?? icon: Icons.handyman,
"Utente", label: context.l10n.homeNewOperationTicket,
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: context.l10n.commonNote,
color: Colors.amber,
onTap: () {
// TODO: Quando faremo il modale/pagina delle note
},
),
const SizedBox(width: 12),
QuickActionButton(
icon: Icons.task_alt,
label: context.l10n.commonTask,
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(
context.l10n.commonComingSoon,
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.name,
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),
); );
}, },
),
],
); );
} }
} }

View 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),
),
],
),
),
);
}
}

View File

@@ -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,49 +81,47 @@ 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, {
required String title, required String title,
required String subtitle, required String subtitle,
required IconData icon, required IconData icon,
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,
), ),
@@ -154,4 +164,5 @@ Widget _buildHubCard(
), ),
), ),
); );
}
} }

View File

@@ -9,19 +9,17 @@ import 'package:get_it/get_it.dart';
part 'product_state.dart'; part 'product_state.dart';
class ProductCubit extends Cubit<ProductState> { class ProductsCubit extends Cubit<ProductState> {
final ProductRepository _repository = GetIt.I<ProductRepository>(); final ProductRepository _repository = GetIt.I<ProductRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>(); final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
ProductCubit() : super(const ProductState()); ProductsCubit() : super(const ProductState());
// Caricamento iniziale dei Brand // Caricamento iniziale dei Brand
Future<void> loadBrands() async { Future<void> loadBrands() async {
emit(state.copyWith(status: ProductStatus.loading)); emit(state.copyWith(status: ProductStatus.loading));
try { try {
final brands = await _repository.getBrands( final brands = await _repository.getBrands();
_sessionCubit.state.company!.id!,
);
emit(state.copyWith(status: ProductStatus.success, brands: brands)); emit(state.copyWith(status: ProductStatus.success, brands: brands));
} catch (e) { } catch (e) {
emit( emit(
@@ -30,6 +28,27 @@ class ProductCubit extends Cubit<ProductState> {
} }
} }
Future<void> loadModels() async {
emit(state.copyWith(status: ProductStatus.loading));
try {
final models = await _repository.getModels();
emit(state.copyWith(status: ProductStatus.success, models: models));
} catch (e) {
emit(
state.copyWith(status: ProductStatus.error, errorMessage: e.toString()),
);
}
}
Future<void> refreshCubit() async {
if (state.selectedBrand != null) {
await selectBrand(state.selectedBrand);
} else {
emit(state.copyWith(status: ProductStatus.initial));
await loadBrands();
}
}
// Selezione Brand e caricamento Modelli // Selezione Brand e caricamento Modelli
Future<void> selectBrand(BrandModel? brand) async { Future<void> selectBrand(BrandModel? brand) async {
if (brand == null) { if (brand == null) {

View File

@@ -1,3 +1,4 @@
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/brand_model.dart'; import '../models/brand_model.dart';
@@ -5,16 +6,17 @@ import '../models/model_model.dart';
class ProductRepository { class ProductRepository {
final SupabaseClient _supabase = GetIt.I<SupabaseClient>(); final SupabaseClient _supabase = GetIt.I<SupabaseClient>();
final String _companyId = GetIt.I<SessionCubit>().state.company!.id!;
// --- BRAND --- // --- BRAND ---
/// Recupera tutti i brand dell'azienda /// Recupera tutti i brand dell'azienda
Future<List<BrandModel>> getBrands(String companyId) async { Future<List<BrandModel>> getBrands() async {
try { try {
final response = await _supabase final response = await _supabase
.from('brand') .from('brand')
.select() .select()
.eq('company_id', companyId) .eq('company_id', _companyId)
.eq('is_active', true) .eq('is_active', true)
.order('name'); .order('name');
@@ -57,6 +59,19 @@ class ProductRepository {
} }
} }
Future<List<ModelModel>> getModels() async {
try {
final response = await _supabase
.from('model')
.select()
.eq('is_active', true)
.order('name');
return (response as List).map((m) => ModelModel.fromJson(m)).toList();
} catch (e) {
throw '$e';
}
}
/// Crea o aggiorna un modello /// Crea o aggiorna un modello
/// NOTA: name_with_brand verrà gestito dal trigger SQL che hai lanciato! /// NOTA: name_with_brand verrà gestito dal trigger SQL che hai lanciato!
Future<ModelModel> upsertModel(ModelModel model) async { Future<ModelModel> upsertModel(ModelModel model) async {

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/core/utils/extensions.dart';
class BrandModel extends Equatable { class BrandModel extends Equatable {
final String? id; final String? id;

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/core/utils/extensions.dart';
class ModelModel extends Equatable { class ModelModel extends Equatable {
final String? id; final String? id;

View File

@@ -33,7 +33,7 @@ class BrandSelector extends StatelessWidget {
return DropdownMenuItem(value: brand, child: Text(brand.name)); return DropdownMenuItem(value: brand, child: Text(brand.name));
}).toList(), }).toList(),
onChanged: (brand) => onChanged: (brand) =>
context.read<ProductCubit>().selectBrand(brand), context.read<ProductsCubit>().selectBrand(brand),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),

View File

@@ -64,7 +64,7 @@ class ModelsList extends StatelessWidget {
color: model.isActive ? context.accent : Colors.grey, color: model.isActive ? context.accent : Colors.grey,
), ),
onPressed: () => context onPressed: () => context
.read<ProductCubit>() .read<ProductsCubit>()
.toggleStatus('model', model.id!, model.isActive), .toggleStatus('model', model.id!, model.isActive),
), ),
], ],

View File

@@ -40,7 +40,7 @@ void _submitBrand(
BrandModel? brand, BrandModel? brand,
) { ) {
if (controller.text.trim().isNotEmpty) { if (controller.text.trim().isNotEmpty) {
context.read<ProductCubit>().saveBrand(controller.text, id: brand?.id); context.read<ProductsCubit>().saveBrand(controller.text, id: brand?.id);
Navigator.pop(context); Navigator.pop(context);
} }
} }
@@ -81,7 +81,7 @@ void _submitModel(
ModelModel? model, ModelModel? model,
) { ) {
if (controller.text.isNotEmpty) { if (controller.text.isNotEmpty) {
context.read<ProductCubit>().saveModel(controller.text, id: model?.id); context.read<ProductsCubit>().saveModel(controller.text, id: model?.id);
Navigator.pop(context); Navigator.pop(context);
} }
} }

View File

@@ -12,7 +12,7 @@ class ProductsScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Carichiamo i brand appena la pagina viene creata // Carichiamo i brand appena la pagina viene creata
context.read<ProductCubit>().loadBrands(); context.read<ProductsCubit>().loadBrands();
return Scaffold( return Scaffold(
backgroundColor: context.background, backgroundColor: context.background,
@@ -33,7 +33,7 @@ class ProductsScreen extends StatelessWidget {
), ),
), ),
), ),
body: BlocConsumer<ProductCubit, ProductState>( body: BlocConsumer<ProductsCubit, ProductState>(
listener: (context, state) { listener: (context, state) {
if (state.status == ProductStatus.error) { if (state.status == ProductStatus.error) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -23,7 +23,7 @@ class _QuickProductDialogState extends State<QuickProductDialog> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final newModel = await context.read<ProductCubit>().quickCreateProduct( final newModel = await context.read<ProductsCubit>().quickCreateProduct(
brandName: _selectedBrandName.trim(), brandName: _selectedBrandName.trim(),
modelName: _modelCtrl.text.trim(), modelName: _modelCtrl.text.trim(),
); );

View File

@@ -51,7 +51,7 @@ class ProviderRepository {
) )
''') ''')
.eq('company_id', companyId) .eq('company_id', companyId)
.order('nome'); .order('name');
return (response as List).map((m) => ProviderModel.fromMap(m)).toList(); return (response as List).map((m) => ProviderModel.fromMap(m)).toList();
} catch (e) { } catch (e) {

View File

@@ -3,28 +3,30 @@ import 'package:flux/features/master_data/store/models/store_model.dart';
class ProviderModel extends Equatable { class ProviderModel extends Equatable {
final String? id; final String? id;
final String nome; final String name;
final bool telefoniaFissa; final bool landline;
final bool telefoniaMobile; final bool mobile;
final bool energia; final bool energy;
final bool assicurazioni; final bool insurance;
final bool intrattenimento; final bool entertainment;
final bool finanziamenti; final bool financing;
final bool altro; final bool telepass;
final bool other;
final bool isActive; final bool isActive;
final String companyId; final String companyId;
final List<StoreModel> associatedStores; final List<StoreModel> associatedStores;
const ProviderModel({ const ProviderModel({
this.id, this.id,
required this.nome, required this.name,
required this.telefoniaFissa, required this.landline,
required this.telefoniaMobile, required this.mobile,
required this.energia, required this.energy,
required this.assicurazioni, required this.insurance,
required this.intrattenimento, required this.entertainment,
required this.finanziamenti, required this.financing,
required this.altro, required this.telepass,
required this.other,
required this.isActive, required this.isActive,
required this.companyId, required this.companyId,
this.associatedStores = const [], this.associatedStores = const [],
@@ -44,14 +46,15 @@ class ProviderModel extends Equatable {
} }
return ProviderModel( return ProviderModel(
id: map['id'], id: map['id'],
nome: map['nome'], name: map['name'],
telefoniaFissa: map['telefonia_fissa'] ?? false, landline: map['landline'] ?? false,
telefoniaMobile: map['telefonia_mobile'] ?? false, mobile: map['mobile'] ?? false,
energia: map['energia'] ?? false, energy: map['energy'] ?? false,
assicurazioni: map['assicurazioni'] ?? false, insurance: map['insurance'] ?? false,
intrattenimento: map['intrattenimento'] ?? false, entertainment: map['entertainment'] ?? false,
finanziamenti: map['finanziamenti'] ?? false, financing: map['financing'] ?? false,
altro: map['altro'] ?? false, telepass: map['telepass'] ?? false,
other: map['other'] ?? false,
isActive: map['is_active'] ?? true, isActive: map['is_active'] ?? true,
companyId: map['company_id'], companyId: map['company_id'],
associatedStores: stores, associatedStores: stores,
@@ -60,14 +63,15 @@ class ProviderModel extends Equatable {
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
final map = { final map = {
'nome': nome, 'name': name,
'telefonia_fissa': telefoniaFissa, 'landline': landline,
'telefonia_mobile': telefoniaMobile, 'mobile': mobile,
'energia': energia, 'energy': energy,
'assicurazioni': assicurazioni, 'insurance': insurance,
'intrattenimento': intrattenimento, 'entertainment': entertainment,
'finanziamenti': finanziamenti, 'financing': financing,
'altro': altro, 'telepass': telepass,
'other': other,
'is_active': isActive, 'is_active': isActive,
'company_id': companyId, 'company_id': companyId,
}; };
@@ -82,14 +86,15 @@ class ProviderModel extends Equatable {
@override @override
List<Object?> get props => [ List<Object?> get props => [
id, id,
nome, name,
telefoniaFissa, landline,
telefoniaMobile, mobile,
energia, energy,
assicurazioni, insurance,
intrattenimento, entertainment,
finanziamenti, financing,
altro, telepass,
other,
isActive, isActive,
companyId, companyId,
associatedStores, associatedStores,
@@ -97,28 +102,30 @@ class ProviderModel extends Equatable {
ProviderModel copyWith({ ProviderModel copyWith({
String? id, String? id,
String? nome, String? name,
bool? telefoniaFissa, bool? landline,
bool? telefoniaMobile, bool? mobile,
bool? energia, bool? energy,
bool? assicurazioni, bool? insurance,
bool? intrattenimento, bool? entertainment,
bool? finanziamenti, bool? financing,
bool? altro, bool? telepass,
bool? other,
bool? isActive, bool? isActive,
String? companyId, String? companyId,
List<StoreModel>? associatedStores, List<StoreModel>? associatedStores,
}) { }) {
return ProviderModel( return ProviderModel(
id: id ?? this.id, id: id ?? this.id,
nome: nome ?? this.nome, name: name ?? this.name,
telefoniaFissa: telefoniaFissa ?? this.telefoniaFissa, landline: landline ?? this.landline,
telefoniaMobile: telefoniaMobile ?? this.telefoniaMobile, mobile: mobile ?? this.mobile,
energia: energia ?? this.energia, energy: energy ?? this.energy,
assicurazioni: assicurazioni ?? this.assicurazioni, insurance: insurance ?? this.insurance,
intrattenimento: intrattenimento ?? this.intrattenimento, entertainment: entertainment ?? this.entertainment,
finanziamenti: finanziamenti ?? this.finanziamenti, financing: financing ?? this.financing,
altro: altro ?? this.altro, telepass: telepass ?? this.telepass,
other: other ?? this.other,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
companyId: companyId ?? this.companyId, companyId: companyId ?? this.companyId,
associatedStores: associatedStores ?? this.associatedStores, associatedStores: associatedStores ?? this.associatedStores,

View File

@@ -15,13 +15,14 @@ class ProviderFormSheet extends StatefulWidget {
class _ProviderFormSheetState extends State<ProviderFormSheet> { class _ProviderFormSheetState extends State<ProviderFormSheet> {
late TextEditingController _nameController; late TextEditingController _nameController;
late bool _telefoniaFissa; late bool _landline;
late bool _telefoniaMobile; late bool _mobile;
late bool _energia; late bool _energy;
late bool _assicurazioni; late bool _insurance;
late bool _intrattenimento; late bool _entertainment;
late bool _finanziamenti; late bool _financing;
late bool _altro; late bool _telepass;
late bool _other;
late bool _isActive; late bool _isActive;
final List<String> _tempSelectedStoreIds = final List<String> _tempSelectedStoreIds =
[]; // Per gestire la selezione temporanea dei negozi []; // Per gestire la selezione temporanea dei negozi
@@ -33,14 +34,15 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
for (final store in p?.associatedStores ?? []) { for (final store in p?.associatedStores ?? []) {
_tempSelectedStoreIds.add(store.id!); _tempSelectedStoreIds.add(store.id!);
} }
_nameController = TextEditingController(text: p?.nome ?? ''); _nameController = TextEditingController(text: p?.name ?? '');
_telefoniaFissa = p?.telefoniaFissa ?? false; _landline = p?.landline ?? false;
_telefoniaMobile = p?.telefoniaMobile ?? false; _mobile = p?.mobile ?? false;
_energia = p?.energia ?? false; _energy = p?.energy ?? false;
_assicurazioni = p?.assicurazioni ?? false; _insurance = p?.insurance ?? false;
_intrattenimento = p?.intrattenimento ?? false; _entertainment = p?.entertainment ?? false;
_finanziamenti = p?.finanziamenti ?? false; _financing = p?.financing ?? false;
_altro = p?.altro ?? false; _telepass = p?.telepass ?? false;
_other = p?.other ?? false;
_isActive = p?.isActive ?? true; _isActive = p?.isActive ?? true;
} }
@@ -57,14 +59,15 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
final cubit = context.read<ProvidersCubit>(); final cubit = context.read<ProvidersCubit>();
final provider = ProviderModel( final provider = ProviderModel(
id: widget.initialProvider?.id, // Se nullo, Supabase farà insert id: widget.initialProvider?.id, // Se nullo, Supabase farà insert
nome: _nameController.text.trim(), name: _nameController.text.trim(),
telefoniaFissa: _telefoniaFissa, landline: _landline,
telefoniaMobile: _telefoniaMobile, mobile: _mobile,
energia: _energia, energy: _energy,
assicurazioni: _assicurazioni, insurance: _insurance,
intrattenimento: _intrattenimento, entertainment: _entertainment,
finanziamenti: _finanziamenti, financing: _financing,
altro: _altro, telepass: _telepass,
other: _other,
isActive: _isActive, isActive: _isActive,
companyId: companyId:
'', // Verrà ignorato dal toMap e gestito dal Cubit/SessionBloc se hai messo la logica lì '', // Verrà ignorato dal toMap e gestito dal Cubit/SessionBloc se hai messo la logica lì
@@ -110,38 +113,43 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
), ),
_buildSwitch( _buildSwitch(
"Energia (Luce/Gas)", "Energia (Luce/Gas)",
_energia, _energy,
(v) => setState(() => _energia = v), (v) => setState(() => _energy = v),
), ),
_buildSwitch( _buildSwitch(
"Telefonia Fissa", "Telefonia Fissa",
_telefoniaFissa, _landline,
(v) => setState(() => _telefoniaFissa = v), (v) => setState(() => _landline = v),
), ),
_buildSwitch( _buildSwitch(
"Telefonia Mobile", "Telefonia Mobile",
_telefoniaMobile, _mobile,
(v) => setState(() => _telefoniaMobile = v), (v) => setState(() => _mobile = v),
), ),
_buildSwitch( _buildSwitch(
"Assicurazioni", "Assicurazioni",
_assicurazioni, _insurance,
(v) => setState(() => _assicurazioni = v), (v) => setState(() => _insurance = v),
), ),
_buildSwitch( _buildSwitch(
"Intrattenimento", "Intrattenimento",
_intrattenimento, _entertainment,
(v) => setState(() => _intrattenimento = v), (v) => setState(() => _entertainment = v),
), ),
_buildSwitch( _buildSwitch(
"Finanziamenti", "Finanziamenti",
_finanziamenti, _financing,
(v) => setState(() => _finanziamenti = v), (v) => setState(() => _financing = v),
),
_buildSwitch(
"Telepass",
_telepass,
(v) => setState(() => _telepass = v),
), ),
_buildSwitch( _buildSwitch(
"Altro/Accessori", "Altro/Accessori",
_altro, _other,
(v) => setState(() => _altro = v), (v) => setState(() => _other = v),
), ),
const Divider(), const Divider(),
_buildSwitch( _buildSwitch(
@@ -164,7 +172,7 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
store.id, store.id,
); );
return CheckboxListTile( return CheckboxListTile(
title: Text(store.nome), title: Text(store.name),
value: isAssociated, value: isAssociated,
onChanged: (val) { onChanged: (val) {
setState(() { setState(() {

View File

@@ -93,7 +93,7 @@ class _ProvidersMasterDataScreenState extends State<ProvidersMasterDataScreen> {
), ),
), ),
title: Text( title: Text(
provider.nome, provider.name,
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
subtitle: _buildCardSubtitle( subtitle: _buildCardSubtitle(
@@ -141,12 +141,13 @@ class _ProvidersMasterDataScreenState extends State<ProvidersMasterDataScreen> {
return Wrap( return Wrap(
spacing: 4, spacing: 4,
children: [ children: [
if (p.telefoniaFissa || p.telefoniaMobile) if (p.landline || p.mobile) _smallTag("📞 Tel", Colors.blue),
_smallTag("📞 Tel", Colors.blue), if (p.energy) _smallTag("⚡ Energy", Colors.orange),
if (p.energia) _smallTag("⚡ Energy", Colors.orange), if (p.insurance) _smallTag("🛡️ Assic", Colors.teal),
if (p.assicurazioni) _smallTag("🛡️ Assic", Colors.teal), if (p.entertainment) _smallTag("📺 Ent", Colors.red),
if (p.intrattenimento) _smallTag("📺 Ent", Colors.red), if (p.financing) _smallTag("💰 Fin", Colors.purple),
if (p.altro) _smallTag("📦 Altro", Colors.grey), if (p.telepass) _smallTag("🏎️ Telepass", Colors.yellow),
if (p.other) _smallTag("📦 Altro", Colors.grey),
], ],
); );
} }

View File

@@ -16,7 +16,7 @@ class StaffCubit extends Cubit<StaffState> {
// Carica tutto lo staff della compagnia // Carica tutto lo staff della compagnia
Future<void> loadAllStaff() async { Future<void> loadAllStaff() async {
emit(state.copyWith(isLoading: true, error: null)); emit(state.copyWith(status: StaffStatus.loading, error: null));
try { try {
final staff = await _repository.getStaffMembers( final staff = await _repository.getStaffMembers(
_sessionCubit.state.company!.id!, _sessionCubit.state.company!.id!,
@@ -27,18 +27,19 @@ class StaffCubit extends Cubit<StaffState> {
} }
emit( emit(
state.copyWith( state.copyWith(
status: StaffStatus.success,
allStaff: staff, allStaff: staff,
isLoading: false,
storesByStaff: storesByStaff, storesByStaff: storesByStaff,
), ),
); );
} catch (e) { } catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString())); emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
} }
} }
Future<List<StoreModel>> loadStoresByStaff(String staffId) async { Future<List<StoreModel>> loadStoresByStaff(String staffId) async {
try { try {
emit(state.copyWith(error: null));
return await _repository.getStaffMemberStore(staffId); return await _repository.getStaffMemberStore(staffId);
} catch (e) { } catch (e) {
emit(state.copyWith(error: e.toString())); emit(state.copyWith(error: e.toString()));
@@ -48,56 +49,105 @@ class StaffCubit extends Cubit<StaffState> {
// Carica lo staff di uno specifico negozio e aggiorna la mappa // Carica lo staff di uno specifico negozio e aggiorna la mappa
Future<void> loadStaffForStore(String storeId) async { Future<void> loadStaffForStore(String storeId) async {
emit(state.copyWith(error: null));
try { try {
final staffInStore = await _repository.getStaffMembersInStore(storeId); final staffInStore = await _repository.getStaffMembersInStore(storeId);
final newMap = Map<String, List<StaffMemberModel>>.from( final newMap = Map<String, List<StaffMemberModel>>.from(
state.staffByStore, state.staffByStore,
); );
newMap[storeId] = staffInStore; newMap[storeId] = staffInStore;
emit(state.copyWith(staffByStore: newMap)); emit(state.copyWith(staffByStore: newMap, storeStaff: staffInStore));
} catch (e) { } catch (e) {
// Qui potresti gestire l'errore silenziosamente per non bloccare tutta l'UI emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
} }
} }
// Salva o aggiorna un membro // Salva o aggiorna un membro
Future<void> saveStaffMember(StaffMemberModel member) async { Future<void> saveStaffMember(StaffMemberModel member) async {
emit(state.copyWith(isLoading: true)); emit(state.copyWith(status: StaffStatus.loading, error: null));
try { try {
await _repository.saveStaffMember(member); await _repository.saveStaffMember(member);
await loadAllStaff(); // Ricarichiamo la lista aggiornata await loadAllStaff(); // Ricarichiamo la lista aggiornata
} catch (e) { } catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString())); emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
}
}
Future<void> inviteStaffMember({
required StaffMemberModel member,
required List<String> selectedStoreIds,
}) async {
emit(state.copyWith(status: StaffStatus.loading, error: null));
try {
// 1. Invitiamo il membro e ci facciamo dare l'ID
final newStaffId = await _repository.inviteStaffMember(member);
// 2. Assegniamo i negozi uno ad uno (usando il metodo che avevi già nel repo!)
if (selectedStoreIds.isNotEmpty) {
final List<Future> assignTasks = [];
for (var storeId in selectedStoreIds) {
assignTasks.add(_repository.assignStaffToStore(newStaffId, storeId));
}
await Future.wait(assignTasks); // In parallelo per la massima velocità
}
// 3. Ricarichiamo la lista globale così la UI si aggiorna
await loadAllStaff();
// (Nota: se hai un loadStaffForStore o loadAllStaff, chiamalo qui per rinfrescare lo stato)
emit(state.copyWith(status: StaffStatus.success));
} catch (e) {
emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
}
}
Future<void> resetPasswordOrResendInviteLink(String email) async {
try {
await _repository.resetPasswordOrResendInviteLink(email);
emit(state.copyWith(status: StaffStatus.emailSent, error: null));
} catch (e) {
emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
} }
} }
// Associa un dipendente a un negozio // Associa un dipendente a un negozio
Future<void> assignMemberToStore(String staffId, String storeId) async { Future<void> assignMemberToStore(String staffId, String storeId) async {
try { try {
await _repository.assignToStore(staffId, storeId); await _repository.assignStaffToStore(staffId, storeId);
final stuffStores = await loadStoresByStaff(staffId); final stuffStores = await loadStoresByStaff(staffId);
final Map<String, List<StoreModel>> storesByStaff = Map.from( final Map<String, List<StoreModel>> storesByStaff = Map.from(
state.storesByStaff, state.storesByStaff,
); );
storesByStaff[staffId] = stuffStores; storesByStaff[staffId] = stuffStores;
emit(state.copyWith(storesByStaff: storesByStaff)); emit(state.copyWith(storesByStaff: storesByStaff, error: null));
} catch (e) { } catch (e) {
emit(state.copyWith(error: "Errore nell'assegnazione: $e")); emit(
state.copyWith(
status: StaffStatus.error,
error: "Errore nell'assegnazione: $e",
),
);
} }
} }
// Rimuove un dipendente da un negozio // Rimuove un dipendente da un negozio
Future<void> removeMemberFromStore(String staffId, String storeId) async { Future<void> removeMemberFromStore(String staffId, String storeId) async {
try { try {
await _repository.removeFromStore(staffId, storeId); await _repository.removeStaffFromStore(staffId, storeId);
final stuffStores = await loadStoresByStaff(staffId); final stuffStores = await loadStoresByStaff(staffId);
final Map<String, List<StoreModel>> storesByStaff = Map.from( final Map<String, List<StoreModel>> storesByStaff = Map.from(
state.storesByStaff, state.storesByStaff,
); );
storesByStaff[staffId] = stuffStores; storesByStaff[staffId] = stuffStores;
emit(state.copyWith(storesByStaff: storesByStaff)); emit(state.copyWith(storesByStaff: storesByStaff, error: null));
} catch (e) { } catch (e) {
emit(state.copyWith(error: "Errore nella rimozione: $e")); emit(
state.copyWith(
status: StaffStatus.error,
error: "Errore nella rimozione: $e",
),
);
} }
} }
@@ -105,7 +155,7 @@ class StaffCubit extends Cubit<StaffState> {
required StaffMemberModel member, required StaffMemberModel member,
required List<String> selectedStoreIds, required List<String> selectedStoreIds,
}) async { }) async {
emit(state.copyWith(isLoading: true)); emit(state.copyWith(status: StaffStatus.loading, error: null));
try { try {
// 1. Salva o aggiorna l'anagrafica (ci serve l'ID) // 1. Salva o aggiorna l'anagrafica (ci serve l'ID)
// Se è un nuovo membro, Supabase ci restituirà l'ID generato // Se è un nuovo membro, Supabase ci restituirà l'ID generato
@@ -120,7 +170,7 @@ class StaffCubit extends Cubit<StaffState> {
if (selectedStoreIds.isNotEmpty) { if (selectedStoreIds.isNotEmpty) {
await Future.wait( await Future.wait(
selectedStoreIds.map( selectedStoreIds.map(
(storeId) => _repository.assignToStore(staffId, storeId), (storeId) => _repository.assignStaffToStore(staffId, storeId),
), ),
); );
} }
@@ -128,9 +178,9 @@ class StaffCubit extends Cubit<StaffState> {
// 3. Rinfresca i dati // 3. Rinfresca i dati
await loadAllStaff(); await loadAllStaff();
emit(state.copyWith(isLoading: false)); emit(state.copyWith(status: StaffStatus.success));
} catch (e) { } catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString())); emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
} }
} }
} }

View File

@@ -1,42 +1,49 @@
part of 'staff_cubit.dart'; part of 'staff_cubit.dart';
enum StaffStatus { initial, loading, success, emailSent, error }
class StaffState extends Equatable { class StaffState extends Equatable {
final StaffStatus status;
final List<StaffMemberModel> allStaff; final List<StaffMemberModel> allStaff;
final Map<String, List<StoreModel>> storesByStaff; final Map<String, List<StoreModel>> storesByStaff;
final Map<String, List<StaffMemberModel>> staffByStore; final Map<String, List<StaffMemberModel>> staffByStore;
final bool isLoading; final List<StaffMemberModel> storeStaff;
final String? error; final String? error;
const StaffState({ const StaffState({
this.status = StaffStatus.initial,
this.allStaff = const [], this.allStaff = const [],
this.storesByStaff = const {}, this.storesByStaff = const {},
this.staffByStore = const {}, this.staffByStore = const {},
this.isLoading = false, this.storeStaff = const [],
this.error, this.error,
}); });
StaffState copyWith({ StaffState copyWith({
StaffStatus? status,
List<StaffMemberModel>? allStaff, List<StaffMemberModel>? allStaff,
Map<String, List<StoreModel>>? storesByStaff, Map<String, List<StoreModel>>? storesByStaff,
Map<String, List<StaffMemberModel>>? staffByStore, Map<String, List<StaffMemberModel>>? staffByStore,
bool? isLoading, List<StaffMemberModel>? storeStaff,
String? error, String? error,
}) { }) {
return StaffState( return StaffState(
status: status ?? this.status,
allStaff: allStaff ?? this.allStaff, allStaff: allStaff ?? this.allStaff,
storesByStaff: storesByStaff ?? this.storesByStaff, storesByStaff: storesByStaff ?? this.storesByStaff,
staffByStore: staffByStore ?? this.staffByStore, staffByStore: staffByStore ?? this.staffByStore,
isLoading: isLoading ?? this.isLoading, storeStaff: storeStaff ?? this.storeStaff,
error: error, error: error,
); );
} }
@override @override
List<Object?> get props => [ List<Object?> get props => [
status,
allStaff, allStaff,
storesByStaff, storesByStaff,
staffByStore, staffByStore,
isLoading, storeStaff,
error, error,
]; ];
} }

View File

@@ -29,6 +29,48 @@ class StaffRepository {
return StaffMemberModel.fromMap(response); return StaffMemberModel.fromMap(response);
} }
// --- LOGICA DI INVITO (Tramite Edge Function) ---
Future<String> inviteStaffMember(StaffMemberModel newMember) async {
if (newMember.email == null || newMember.email!.isEmpty) {
throw Exception(
"L'indirizzo email è obbligatorio per invitare un collega.",
);
}
try {
final response = await _supabase.functions.invoke(
'invite_staff',
body: newMember.toMap(),
);
if (response.status != 200) {
throw Exception("Errore dal server: ${response.data}");
}
// La funzione ci restituisce l'ID fresco di database!
final responseData = response.data as Map<String, dynamic>;
return responseData['user_id'] as String;
} on FunctionException catch (e) {
throw Exception(
"Errore di comunicazione con il server: ${e.reasonPhrase}",
);
} catch (e) {
throw Exception("Impossibile invitare il collega: $e");
}
}
Future<void> resetPasswordOrResendInviteLink(String email) async {
try {
await _supabase.auth.resetPasswordForEmail(
email,
redirectTo: 'https://flux-web-invite.marco-6ba.workers.dev/',
);
} catch (e) {
throw Exception("Errore nell'invio del link: $e");
}
}
// --- LOGICA DI GIUNZIONE (Staff <-> Store) --- // --- LOGICA DI GIUNZIONE (Staff <-> Store) ---
// Recupera i membri assegnati a uno specifico negozio // Recupera i membri assegnati a uno specifico negozio
@@ -62,7 +104,7 @@ class StaffRepository {
} }
// Assegna un membro a un negozio // Assegna un membro a un negozio
Future<void> assignToStore(String staffId, String storeId) async { Future<void> assignStaffToStore(String staffId, String storeId) async {
await _supabase.from('staff_in_stores').insert({ await _supabase.from('staff_in_stores').insert({
'staff_member_id': staffId, 'staff_member_id': staffId,
'store_id': storeId, 'store_id': storeId,
@@ -70,7 +112,7 @@ class StaffRepository {
} }
// Rimuove l'assegnazione // Rimuove l'assegnazione
Future<void> removeFromStore(String staffId, String storeId) async { Future<void> removeStaffFromStore(String staffId, String storeId) async {
await _supabase await _supabase
.from('staff_in_stores') .from('staff_in_stores')
.delete() .delete()

View File

@@ -25,6 +25,7 @@ class StaffMemberModel extends Equatable {
final String? jobTitle; final String? jobTitle;
final SystemRole systemRole; final SystemRole systemRole;
final bool isActive; final bool isActive;
final bool hasJoined;
const StaffMemberModel({ const StaffMemberModel({
this.id, this.id,
@@ -36,6 +37,7 @@ class StaffMemberModel extends Equatable {
this.jobTitle, this.jobTitle,
this.systemRole = SystemRole.user, this.systemRole = SystemRole.user,
this.isActive = true, this.isActive = true,
this.hasJoined = false,
}); });
StaffMemberModel copyWith({ StaffMemberModel copyWith({
@@ -49,6 +51,7 @@ class StaffMemberModel extends Equatable {
String? jobTitle, String? jobTitle,
SystemRole? systemRole, SystemRole? systemRole,
bool? isActive, bool? isActive,
bool? hasJoined,
}) { }) {
return StaffMemberModel( return StaffMemberModel(
id: id ?? this.id, id: id ?? this.id,
@@ -60,6 +63,7 @@ class StaffMemberModel extends Equatable {
jobTitle: jobTitle ?? this.jobTitle, jobTitle: jobTitle ?? this.jobTitle,
systemRole: systemRole ?? this.systemRole, systemRole: systemRole ?? this.systemRole,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
hasJoined: hasJoined ?? this.hasJoined,
); );
} }
@@ -71,8 +75,6 @@ class StaffMemberModel extends Equatable {
email: '', email: '',
phoneNumber: '', phoneNumber: '',
jobTitle: '', jobTitle: '',
systemRole: SystemRole.user,
isActive: true,
); );
} }
@@ -87,6 +89,7 @@ class StaffMemberModel extends Equatable {
jobTitle: map['job_title'] as String?, jobTitle: map['job_title'] as String?,
systemRole: SystemRole.fromString(map['system_role']), systemRole: SystemRole.fromString(map['system_role']),
isActive: map['is_active'] ?? true, isActive: map['is_active'] ?? true,
hasJoined: map['has_joined'] ?? false,
); );
} }
@@ -101,6 +104,7 @@ class StaffMemberModel extends Equatable {
if (jobTitle != null) 'job_title': jobTitle, if (jobTitle != null) 'job_title': jobTitle,
'system_role': systemRole.name, // Trasforma SystemRole.admin in 'admin' 'system_role': systemRole.name, // Trasforma SystemRole.admin in 'admin'
'is_active': isActive, 'is_active': isActive,
'has_joined': hasJoined,
}; };
} }
@@ -115,5 +119,6 @@ class StaffMemberModel extends Equatable {
jobTitle, jobTitle,
systemRole, systemRole,
isActive, isActive,
hasJoined,
]; ];
} }

View File

@@ -28,6 +28,14 @@ class _StaffScreenState extends State<StaffScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 1. Peschiamo chi siamo noi e che poteri abbiamo
final myRole = context
.read<SessionCubit>()
.state
.currentStaffMember
?.systemRole;
final canManageStaff =
myRole == SystemRole.admin || myRole == SystemRole.manager;
return Scaffold( return Scaffold(
backgroundColor: context.background, backgroundColor: context.background,
appBar: AppBar( appBar: AppBar(
@@ -45,7 +53,18 @@ class _StaffScreenState extends State<StaffScreen> {
), ),
], ],
), ),
body: Column( body: BlocListener<StaffCubit, StaffState>(
listener: (context, state) {
if (state.status == StaffStatus.error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.error ?? 'Errore sconosciuto'),
backgroundColor: Colors.red,
),
);
}
},
child: Column(
children: [ children: [
// --- BARRA FILTRO NEGOZIO (Visibile solo se non 'Tutta l'Azienda') --- // --- BARRA FILTRO NEGOZIO (Visibile solo se non 'Tutta l'Azienda') ---
AnimatedContainer( AnimatedContainer(
@@ -64,7 +83,7 @@ class _StaffScreenState extends State<StaffScreen> {
? state.allStaff ? state.allStaff
: (state.staffByStore[_selectedStoreId] ?? []); : (state.staffByStore[_selectedStoreId] ?? []);
if (state.isLoading && list.isEmpty) { if (state.status == StaffStatus.loading && list.isEmpty) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -86,11 +105,14 @@ class _StaffScreenState extends State<StaffScreen> {
), ),
], ],
), ),
floatingActionButton: FloatingActionButton.extended( ),
floatingActionButton: canManageStaff
? FloatingActionButton.extended(
onPressed: () => _openStaffForm(context), onPressed: () => _openStaffForm(context),
label: const Text("Aggiungi"), label: const Text("Aggiungi"),
icon: const Icon(Icons.person_add_alt_1), icon: const Icon(Icons.person_add_alt_1),
), )
: null,
); );
} }
@@ -104,7 +126,7 @@ class _StaffScreenState extends State<StaffScreen> {
initialValue: _selectedStoreId, initialValue: _selectedStoreId,
decoration: const InputDecoration(labelText: "Filtra per Negozio"), decoration: const InputDecoration(labelText: "Filtra per Negozio"),
items: state.stores items: state.stores
.map((s) => DropdownMenuItem(value: s.id, child: Text(s.nome))) .map((s) => DropdownMenuItem(value: s.id, child: Text(s.name)))
.toList(), .toList(),
onChanged: (id) { onChanged: (id) {
setState(() => _selectedStoreId = id); setState(() => _selectedStoreId = id);
@@ -117,6 +139,13 @@ class _StaffScreenState extends State<StaffScreen> {
} }
Widget _buildStaffCard(StaffMemberModel member) { Widget _buildStaffCard(StaffMemberModel member) {
final myRole = context
.read<SessionCubit>()
.state
.currentStaffMember
?.systemRole;
final canManageStaff =
myRole == SystemRole.admin || myRole == SystemRole.manager;
return Card( return Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -145,8 +174,48 @@ class _StaffScreenState extends State<StaffScreen> {
), ),
], ],
), ),
trailing: const Icon(Icons.edit_note), trailing: Row(
onTap: () => _openStaffForm(context, member: member), mainAxisSize: MainAxisSize.min,
children: [
if (member.jobTitle != null && member.jobTitle!.isNotEmpty) ...[
Text('Qualifica: ${member.jobTitle!}'),
const SizedBox(width: 8),
],
if (canManageStaff) ...[
const SizedBox(width: 8),
if (!member.hasJoined)
ElevatedButton.icon(
icon: const Icon(Icons.send),
label: const Text("Re-invia Invito (In Attesa)"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
onPressed: () {
// Chiama la funzione di reset password mascherata da invito
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
member.email!,
);
},
)
else
OutlinedButton.icon(
icon: const Icon(Icons.lock_reset),
label: const Text("Invia Reset Password"),
onPressed: () {
// Chiama LA STESSA IDENTICA FUNZIONE!
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
member.email!,
);
},
),
],
],
),
onTap: () =>
canManageStaff ? _openStaffForm(context, member: member) : null,
), ),
); );
} }
@@ -155,9 +224,10 @@ class _StaffScreenState extends State<StaffScreen> {
final nameController = TextEditingController(text: member?.name); final nameController = TextEditingController(text: member?.name);
final emailController = TextEditingController(text: member?.email); final emailController = TextEditingController(text: member?.email);
final phoneController = TextEditingController(text: member?.phoneNumber); final phoneController = TextEditingController(text: member?.phoneNumber);
final jobTitleController = TextEditingController(text: member?.jobTitle);
// 1. Inizializziamo la lista temporanea attingendo dallo stato del Cubit // Variabili di stato per il BottomSheet
// Usiamo storesByStaff (la mappa che indicizza i negozi per ogni ID dipendente) SystemRole selectedRole = member?.systemRole ?? SystemRole.user;
List<String> tempSelectedStores = List<String> tempSelectedStores =
context context
.read<StaffCubit>() .read<StaffCubit>()
@@ -172,7 +242,6 @@ class _StaffScreenState extends State<StaffScreen> {
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (context) => StatefulBuilder( builder: (context) => StatefulBuilder(
// <--- QUESTO è il segreto per le Chip
builder: (context, setModalState) { builder: (context, setModalState) {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -194,7 +263,7 @@ class _StaffScreenState extends State<StaffScreen> {
children: [ children: [
Text( Text(
member == null member == null
? "Nuovo Collaboratore" ? "Invita Collaboratore" // Cambiato il titolo per chiarezza!
: "Modifica Collaboratore", : "Modifica Collaboratore",
style: const TextStyle( style: const TextStyle(
fontSize: 20, fontSize: 20,
@@ -202,32 +271,77 @@ class _StaffScreenState extends State<StaffScreen> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
FluxTextField( FluxTextField(
controller: nameController, controller: nameController,
label: "Nome e Cognome", label: "Nome e Cognome",
icon: Icons.person, icon: Icons.person,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Reso visivamente obbligatorio se è un nuovo utente
FluxTextField( FluxTextField(
controller: emailController, controller: emailController,
label: "Email", label: member == null
? "Email (Obbligatoria per invito)*"
: "Email",
icon: Icons.email, icon: Icons.email,
enabled:
member ==
null, // UX: Di solito l'email non si cambia dopo l'invito
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FluxTextField( FluxTextField(
controller: phoneController, controller: phoneController,
label: "Telefono", label: "Telefono",
icon: Icons.phone, icon: Icons.phone,
), ),
const SizedBox(height: 16),
// --- NOVITÀ: SCELTA DEL RUOLO E MANSIONE ---
Row(
children: [
Expanded(
flex: 2,
child: DropdownButtonFormField<SystemRole>(
initialValue: selectedRole,
decoration: const InputDecoration(
labelText: "Ruolo di Sistema",
prefixIcon: Icon(Icons.admin_panel_settings),
),
items: SystemRole.values.map((role) {
return DropdownMenuItem(
value: role,
child: Text(role.name.toUpperCase()),
);
}).toList(),
onChanged: (val) {
if (val != null) {
setModalState(() => selectedRole = val);
}
},
),
),
const SizedBox(width: 16),
Expanded(
flex: 3,
child: FluxTextField(
controller: jobTitleController,
label: "Qualifica (Es. Addetto)",
icon: Icons.badge,
),
),
],
),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text( const Text(
"Assegna ai Negozi", "Assegna ai Negozi",
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// --- SELETTORE NEGOZI (CHIPS) ---
// Qui usiamo il BlocBuilder per i negozi, ma il setModalState per il refresh
BlocBuilder<StoreCubit, StoreState>( BlocBuilder<StoreCubit, StoreState>(
builder: (context, storeState) { builder: (context, storeState) {
if (storeState.status == StoreStatus.loading) { if (storeState.status == StoreStatus.loading) {
@@ -241,10 +355,9 @@ class _StaffScreenState extends State<StaffScreen> {
store.id, store.id,
); );
return FilterChip( return FilterChip(
label: Text(store.nome), label: Text(store.name),
selected: isSelected, selected: isSelected,
onSelected: (selected) { onSelected: (selected) {
// IMPORTANTE: setModalState aggiorna l'UI del BottomSheet
setModalState(() { setModalState(() {
if (selected) { if (selected) {
tempSelectedStores.add(store.id!); tempSelectedStores.add(store.id!);
@@ -269,11 +382,26 @@ class _StaffScreenState extends State<StaffScreen> {
height: 50, height: 50,
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
// Validazione di base per i nuovi inviti
if (member == null &&
emailController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"L'email è obbligatoria per invitare!",
),
),
);
return;
}
final updatedMember = StaffMemberModel( final updatedMember = StaffMemberModel(
id: member?.id, id: member?.id, // Sarà null se è nuovo
name: nameController.text, name: nameController.text.trim(),
email: emailController.text, email: emailController.text.trim(),
phoneNumber: phoneController.text, phoneNumber: phoneController.text.trim(),
jobTitle: jobTitleController.text.trim(),
systemRole: selectedRole,
companyId: GetIt.I companyId: GetIt.I
.get<SessionCubit>() .get<SessionCubit>()
.state .state
@@ -282,17 +410,56 @@ class _StaffScreenState extends State<StaffScreen> {
userId: GetIt.I.get<SessionCubit>().state.user!.id, userId: GetIt.I.get<SessionCubit>().state.user!.id,
); );
// Chiamiamo il metodo atomico nel Cubit // --- IL BIVIO LOGICO MAGICO ---
if (member == null) {
// 1. UTENTE NUOVO -> Chiamiamo la Edge Function
// (Nota: Per i negozi, potresti dover fare una logica a parte nel Cubit
// perché l'ID del database viene generato DOPO che l'Edge Function ha finito)
context.read<StaffCubit>().inviteStaffMember(
member: updatedMember,
selectedStoreIds: tempSelectedStores,
);
} else {
// 2. UTENTE ESISTENTE -> Modifica classica
context.read<StaffCubit>().saveStaffWithStores( context.read<StaffCubit>().saveStaffWithStores(
member: updatedMember, member: updatedMember,
selectedStoreIds: tempSelectedStores, selectedStoreIds: tempSelectedStores,
); );
}
Navigator.pop(context); Navigator.pop(context);
}, },
child: const Text("SALVA COLLABORATORE"), child: Text(
member == null ? "INVIA INVITO" : "SALVA MODIFICHE",
), ),
), ),
),
/* const SizedBox(height: 16),
if (!member!.hasJoined)
ElevatedButton.icon(
icon: const Icon(Icons.send),
label: const Text("Re-invia Invito (In Attesa)"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
onPressed: () {
// Chiama la funzione di reset password mascherata da invito
context
.read<StaffCubit>()
.resetPasswordOrResendInviteLink(member.email!);
},
)
else
OutlinedButton.icon(
icon: const Icon(Icons.lock_reset),
label: const Text("Invia Reset Password"),
onPressed: () {
// Chiama LA STESSA IDENTICA FUNZIONE!
context
.read<StaffCubit>()
.resetPasswordOrResendInviteLink(member.email!);
},
), */
], ],
), ),
), ),

View File

@@ -137,7 +137,7 @@ class StoreCubit extends Cubit<StoreState> {
Future<void> assignStaffToStore(String storeId, String staffId) async { Future<void> assignStaffToStore(String storeId, String staffId) async {
try { try {
await _staffRepository.assignToStore(staffId, storeId); await _staffRepository.assignStaffToStore(staffId, storeId);
// Dopo l'assegnazione, potresti voler ricaricare lo staff per quel negozio // Dopo l'assegnazione, potresti voler ricaricare lo staff per quel negozio
loadStores(); loadStores();
} catch (e) { } catch (e) {
@@ -150,7 +150,7 @@ class StoreCubit extends Cubit<StoreState> {
// Rimuove un dipendente da un negozio // Rimuove un dipendente da un negozio
Future<void> removeStaffFromStore(String staffId, String storeId) async { Future<void> removeStaffFromStore(String staffId, String storeId) async {
try { try {
await _staffRepository.removeFromStore(staffId, storeId); await _staffRepository.removeStaffFromStore(staffId, storeId);
loadStores(); loadStores();
} catch (e) { } catch (e) {
emit( emit(

View File

@@ -98,7 +98,7 @@ class StoreRepository {
) )
''') ''')
.eq('company_id', companyId) .eq('company_id', companyId)
.order('nome'); .order('name');
return (response as List).map((m) => StoreModel.fromMap(m)).toList(); return (response as List).map((m) => StoreModel.fromMap(m)).toList();
} catch (e) { } catch (e) {

View File

@@ -4,30 +4,30 @@ import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
class StoreModel extends Equatable { class StoreModel extends Equatable {
final String? id; final String? id;
final String nome; final String name;
final String companyId; final String companyId;
final bool isActive; final bool isActive;
final bool isPaid; final bool isPaid;
final DateTime? paymentExpiration; final DateTime? paymentExpiration;
final String indirizzo; final String address;
final String cap; final String zipCode;
final String comune; final String city;
final String provincia; final String province;
final List<ProviderModel> associatedProviders; // Provider associati final List<ProviderModel> associatedProviders; // Provider associati
final List<StaffMemberModel> final List<StaffMemberModel>
associatedStaffMembers; // Membri dello staff associati associatedStaffMembers; // Membri dello staff associati
const StoreModel({ const StoreModel({
this.id, this.id,
required this.nome, required this.name,
required this.companyId, required this.companyId,
this.isActive = true, this.isActive = true,
this.isPaid = false, this.isPaid = false,
this.paymentExpiration, this.paymentExpiration,
required this.indirizzo, required this.address,
required this.cap, required this.zipCode,
required this.comune, required this.city,
required this.provincia, required this.province,
this.associatedProviders = const [], this.associatedProviders = const [],
this.associatedStaffMembers = const [], this.associatedStaffMembers = const [],
}); });
@@ -36,15 +36,15 @@ class StoreModel extends Equatable {
@override @override
List<Object?> get props => [ List<Object?> get props => [
id, id,
nome, name,
companyId, companyId,
isActive, isActive,
isPaid, isPaid,
paymentExpiration, paymentExpiration,
indirizzo, address,
cap, zipCode,
comune, city,
provincia, province,
associatedProviders, associatedProviders,
associatedStaffMembers, associatedStaffMembers,
]; ];
@@ -52,29 +52,29 @@ class StoreModel extends Equatable {
// Il mitico copyWith per creare nuove istanze modificando solo ciò che serve // Il mitico copyWith per creare nuove istanze modificando solo ciò che serve
StoreModel copyWith({ StoreModel copyWith({
String? id, String? id,
String? nome, String? name,
String? companyId, String? companyId,
bool? isActive, bool? isActive,
bool? isPaid, bool? isPaid,
DateTime? paymentExpiration, DateTime? paymentExpiration,
String? indirizzo, String? address,
String? cap, String? zipCode,
String? comune, String? city,
String? provincia, String? province,
List<ProviderModel>? associatedProviders, List<ProviderModel>? associatedProviders,
List<StaffMemberModel>? associatedStaffMembers, List<StaffMemberModel>? associatedStaffMembers,
}) { }) {
return StoreModel( return StoreModel(
id: id ?? this.id, id: id ?? this.id,
nome: nome ?? this.nome, name: name ?? this.name,
companyId: companyId ?? this.companyId, companyId: companyId ?? this.companyId,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
isPaid: isPaid ?? this.isPaid, isPaid: isPaid ?? this.isPaid,
paymentExpiration: paymentExpiration ?? this.paymentExpiration, paymentExpiration: paymentExpiration ?? this.paymentExpiration,
indirizzo: indirizzo ?? this.indirizzo, address: address ?? this.address,
cap: cap ?? this.cap, zipCode: zipCode ?? this.zipCode,
comune: comune ?? this.comune, city: city ?? this.city,
provincia: provincia ?? this.provincia, province: province ?? this.province,
associatedProviders: associatedProviders ?? this.associatedProviders, associatedProviders: associatedProviders ?? this.associatedProviders,
associatedStaffMembers: associatedStaffMembers:
associatedStaffMembers ?? this.associatedStaffMembers, associatedStaffMembers ?? this.associatedStaffMembers,
@@ -83,12 +83,12 @@ class StoreModel extends Equatable {
factory StoreModel.empty() { factory StoreModel.empty() {
return const StoreModel( return const StoreModel(
nome: '', name: '',
companyId: '', companyId: '',
indirizzo: '', address: '',
cap: '', zipCode: '',
comune: '', city: '',
provincia: '', province: '',
); );
} }
@@ -118,17 +118,17 @@ class StoreModel extends Equatable {
} }
return StoreModel( return StoreModel(
id: map['id'] as String, id: map['id'] as String,
nome: map['nome'], name: map['name'],
companyId: map['company_id'] as String, companyId: map['company_id'] as String,
isActive: map['is_active'] ?? true, isActive: map['is_active'] ?? true,
isPaid: map['is_paid'] ?? false, isPaid: map['is_paid'] ?? false,
paymentExpiration: map['payment_expiration'] != null paymentExpiration: map['payment_expiration'] != null
? DateTime.parse(map['payment_expiration']) ? DateTime.parse(map['payment_expiration'])
: null, : null,
indirizzo: map['indirizzo'], address: map['address'],
cap: map['cap'], zipCode: map['zip_code'],
comune: map['comune'], city: map['city'],
provincia: map['provincia'], province: map['province'],
associatedProviders: providers, associatedProviders: providers,
associatedStaffMembers: staffMembers, associatedStaffMembers: staffMembers,
); );
@@ -137,16 +137,16 @@ class StoreModel extends Equatable {
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
if (id != null) 'id': id, if (id != null) 'id': id,
'nome': nome, 'name': name,
'company_id': companyId, 'company_id': companyId,
'is_active': isActive, 'is_active': isActive,
'is_paid': isPaid, 'is_paid': isPaid,
if (paymentExpiration != null) if (paymentExpiration != null)
'payment_expiration': paymentExpiration!.toIso8601String(), 'payment_expiration': paymentExpiration!.toIso8601String(),
'indirizzo': indirizzo, 'address': address,
'cap': cap, 'zip_code': zipCode,
'comune': comune, 'city': city,
'provincia': provincia, 'province': province,
}; };
} }
} }

View File

@@ -37,14 +37,14 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
final company = context.read<SessionCubit>().state.company; final company = context.read<SessionCubit>().state.company;
if (company != null) { if (company != null) {
setState(() { setState(() {
_indirizzoController.text = company.indirizzo; _indirizzoController.text = company.address;
_capController.text = company.cap; _capController.text = company.zipCode;
_comuneController.text = _comuneController.text =
company.citta; // Nel DB company è 'citta', store è 'comune' company.city; // Nel DB company è 'citta', store è 'comune'
_provinciaController.text = company.provincia; _provinciaController.text = company.province;
// Suggeriamo anche un nome se vuoto // Suggeriamo anche un nome se vuoto
if (_nomeController.text.isEmpty) { if (_nomeController.text.isEmpty) {
_nomeController.text = '${company.ragioneSociale} - Sede'; _nomeController.text = '${company.name} - Sede';
} }
}); });
@@ -68,12 +68,12 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
} }
final store = StoreModel( final store = StoreModel(
nome: _nomeController.text.trim(), name: _nomeController.text.trim(),
companyId: company.id!, companyId: company.id!,
indirizzo: _indirizzoController.text.trim(), address: _indirizzoController.text.trim(),
cap: _capController.text.trim(), zipCode: _capController.text.trim(),
comune: _comuneController.text.trim(), city: _comuneController.text.trim(),
provincia: _provinciaController.text.trim().toUpperCase(), province: _provinciaController.text.trim().toUpperCase(),
); );
context.read<StoreCubit>().createStore(store); context.read<StoreCubit>().createStore(store);

View File

@@ -53,11 +53,11 @@ class _StoreCardState extends State<StoreCard> {
color: widget.store.isActive ? context.accent : Colors.grey, color: widget.store.isActive ? context.accent : Colors.grey,
), ),
title: Text( title: Text(
widget.store.nome, widget.store.name,
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
subtitle: Text( subtitle: Text(
"${widget.store.comune} (${widget.store.provincia}) - ${widget.store.indirizzo}", "${widget.store.city} (${widget.store.province}) - ${widget.store.address}",
), ),
trailing: Switch( trailing: Switch(
value: widget.store.isActive, value: widget.store.isActive,
@@ -129,7 +129,7 @@ class _StoreCardState extends State<StoreCard> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
"Personale di ${store.nome}", "Personale di ${store.name}",
style: context.titleLarge, style: context.titleLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -184,14 +184,14 @@ class _StoreCardState extends State<StoreCard> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text("Providers di ${store.nome}", style: context.titleLarge), Text("Providers di ${store.name}", style: context.titleLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
...state.allProviders.map((provider) { ...state.allProviders.map((provider) {
final isAssociated = _tempAssociatedProviders.any( final isAssociated = _tempAssociatedProviders.any(
(p) => p.id == provider.id, (p) => p.id == provider.id,
); );
return CheckboxListTile( return CheckboxListTile(
title: Text(provider.nome), title: Text(provider.name),
value: isAssociated, value: isAssociated,
onChanged: (selected) { onChanged: (selected) {
if (selected == true) { if (selected == true) {

View File

@@ -24,11 +24,11 @@ class _StoreFormState extends State<StoreForm> {
void initState() { void initState() {
super.initState(); super.initState();
if (widget.store != null) { if (widget.store != null) {
nomeController.text = widget.store!.nome; nomeController.text = widget.store!.name;
indirizzoController.text = widget.store!.indirizzo; indirizzoController.text = widget.store!.address;
capController.text = widget.store!.cap; capController.text = widget.store!.zipCode;
comuneController.text = widget.store!.comune; comuneController.text = widget.store!.city;
provinciaController.text = widget.store!.provincia; provinciaController.text = widget.store!.province;
} }
} }
@@ -124,11 +124,11 @@ class _StoreFormState extends State<StoreForm> {
id: widget id: widget
.store .store
?.id, // Se nullo, Supabase ne crea uno nuovo ?.id, // Se nullo, Supabase ne crea uno nuovo
nome: nomeController.text, name: nomeController.text,
indirizzo: indirizzoController.text, address: indirizzoController.text,
cap: capController.text, zipCode: capController.text,
comune: comuneController.text, city: comuneController.text,
provincia: provinciaController.text, province: provinciaController.text,
companyId: context companyId: context
.read<SessionCubit>() .read<SessionCubit>()
.state .state

View File

@@ -13,17 +13,19 @@ class OnboardingCubit extends Cubit<OnboardingState> {
final SessionCubit _sessionCubit; final SessionCubit _sessionCubit;
OnboardingCubit(this._sessionCubit, this._repository) OnboardingCubit(this._sessionCubit, this._repository)
: super(OnboardingState( : super(
OnboardingState(
step: _sessionCubit.state.onboardingStep, step: _sessionCubit.state.onboardingStep,
companyId: _sessionCubit.state.company?.id, companyId: _sessionCubit.state.company?.id,
storeId: _sessionCubit.state.currentStore?.id, storeId: _sessionCubit.state.currentStore?.id,
)); ),
);
// --- STEP 1: REGISTRAZIONE AZIENDA --- // --- STEP 1: REGISTRAZIONE AZIENDA ---
Future<void> saveCompany(String companyName) async { Future<void> saveCompany(String companyName) async {
emit(state.copyWith(isLoading: true)); emit(state.copyWith(isLoading: true));
final company = CompanyModel.empty().copyWith( final company = CompanyModel.empty().copyWith(
ragioneSociale: companyName, name: companyName,
userId: GetIt.I<SupabaseClient>().auth.currentUser!.id, userId: GetIt.I<SupabaseClient>().auth.currentUser!.id,
subscriptionTier: SubscriptionTier.pro, subscriptionTier: SubscriptionTier.pro,
subscriptionStatus: SubscriptionStatus.trialing, subscriptionStatus: SubscriptionStatus.trialing,
@@ -86,11 +88,17 @@ class OnboardingCubit extends Cubit<OnboardingState> {
// PARANOIA MODE: Forziamo i legami e il ruolo di sistema 'admin' // PARANOIA MODE: Forziamo i legami e il ruolo di sistema 'admin'
final staffToSave = staff.copyWith( final staffToSave = staff.copyWith(
companyId: state.companyId!, companyId: state.companyId!,
userId: _sessionCubit.state.user!.id, // Dall'utente loggato in Supabase userId: _sessionCubit.state.user!.id,
systemRole: SystemRole.admin, // Blindato! systemRole: SystemRole.admin,
); );
await _repository.createStaffMember(staffToSave); // 1. Salviamo lo staff e CI FACCIAMO RESTITUIRE IL MODELLO (con l'id generato!)
final savedStaff = await _repository.createStaffMember(staffToSave);
// 2. LA MAGIA: Colleghiamo il Paziente Zero al Negozio appena creato!
if (state.storeId != null && savedStaff.id != null) {
await _repository.assignStaffToStore(savedStaff.id!, state.storeId!);
}
emit(state.copyWith(isLoading: false, step: OnboardingStep.completed)); emit(state.copyWith(isLoading: false, step: OnboardingStep.completed));

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; 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/utils/validators.dart'; import 'package:flux/core/utils/validators.dart';
import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart'; import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
import 'package:get_it/get_it.dart';
class StaffOnboardingForm extends StatefulWidget { class StaffOnboardingForm extends StatefulWidget {
const StaffOnboardingForm({super.key}); const StaffOnboardingForm({super.key});
@@ -26,6 +28,12 @@ class _StaffOnboardingFormState extends State<StaffOnboardingForm> {
super.dispose(); super.dispose();
} }
@override
void initState() {
_emailCtrl.text = GetIt.I.get<SessionCubit>().state.user?.email ?? '';
super.initState();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; // <-- IMPORTANTE per i formatter import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/master_data/store/models/store_model.dart'; import 'package:flux/features/master_data/store/models/store_model.dart';
@@ -134,12 +135,12 @@ class _StoreOnboardingFormState extends State<StoreOnboardingForm> {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
// MIRACOLO DELLA FACTORY EMPTY! // MIRACOLO DELLA FACTORY EMPTY!
final newStore = StoreModel.empty().copyWith( final newStore = StoreModel.empty().copyWith(
nome: _nameCtrl.text.trim(), name: _nameCtrl.text.trim(),
indirizzo: _addressCtrl.text.trim(), address: _addressCtrl.text.trim(),
comune: _cityCtrl.text.trim(), city: _cityCtrl.text.trim(),
cap: _zipCodeCtrl.text.trim(), zipCode: _zipCodeCtrl.text.trim(),
// Formattiamo in maiuscolo qui, al momento del salvataggio! // Formattiamo in maiuscolo qui, al momento del salvataggio!
provincia: _provinceCtrl.text.trim().toUpperCase(), province: _provinceCtrl.text.trim().toUpperCase(),
); );
context.read<OnboardingCubit>().saveStore(newStore); context.read<OnboardingCubit>().saveStore(newStore);
} }

View File

@@ -0,0 +1,389 @@
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:get_it/get_it.dart';
import 'package:image_picker/image_picker.dart';
part 'operation_files_events.dart';
part 'operation_files_state.dart';
class OperationFilesBloc
extends Bloc<OperationFilesEvent, OperationFilesState> {
final _repository = GetIt.I.get<OperationsRepository>();
final String? operationId;
OperationFilesBloc({this.operationId})
: super(
OperationFilesState(
status: OperationFilesStatus.initial,
operationId: operationId,
),
) {
on<OperationsavedEvent>(_onOperationsaved);
on<LoadOperationFilesEvent>(_onLoadOperationFiles);
on<AddOperationFilesEvent>(_onAddOperationFiles);
on<UploadOperationFilesEvent>(_onUploadOperationFiles);
on<DeleteOperationFilesEvent>(_onDeleteOperationFiles);
on<ToggleOperationFileSelectionEvent>(_onToggleOperationFileSelection);
on<LinkFilesToCustomerEvent>(_onLinkFilesToCustomer);
on<RenameOperationFileEvent>(_onRenameOperationFile);
on<DeleteSpecificOperationFileEvent>(_onDeleteSpecificOperationFiles);
on<SelectAllOperationFilesEvent>(_onSelectAllOperationFiles);
on<ClearOperationFileSelectionEvent>(_onClearOperationFileSelection);
// Se il BLoC nasce con un ID, accendiamo subito lo stream!
if (operationId != null) {
add(LoadOperationFilesEvent(operationId: operationId));
}
}
FutureOr<void> _onOperationsaved(
OperationsavedEvent event,
Emitter<OperationFilesState> emit,
) async {
// 1. Aggiorniamo l'ID e mettiamo in loading
emit(
state.copyWith(
operationId: event.operationId,
status: OperationFilesStatus.uploading,
),
);
// 2. RECUPERO E UPLOAD DEI FILE "PARCHEGGIATI" (Pratica Nuova)
if (state.localFiles.isNotEmpty) {
try {
final List<Future<void>> uploadTasks = [];
for (var file in state.localFiles) {
// Ricreiamo il PlatformFile dal nostro AttachmentModel
// così il repository lo accetta senza fare storie!
final fakePlatformFile = PlatformFile(
name: '${file.name}.${file.extension}',
size: file.fileSize,
bytes: file.localBytes,
);
uploadTasks.add(
_repository.uploadAndRegisterOperationFile(
operationId: event.operationId, // L'ID APPENA NATO!
pickedFile: fakePlatformFile,
),
);
}
// Lanciamo tutti gli upload in parallelo
await Future.wait(uploadTasks);
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: "Errore upload post-salvataggio: $e",
),
);
return; // Ci fermiamo qui se esplode qualcosa
}
}
// 3. FINE DEI GIOCHI! Svuotiamo i locali, passiamo a success e accendiamo lo Stream
emit(state.copyWith(localFiles: [], status: OperationFilesStatus.success));
add(LoadOperationFilesEvent(operationId: event.operationId));
}
FutureOr<void> _onLoadOperationFiles(
LoadOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) async {
final currentId = event.operationId ?? state.operationId;
if (currentId != null) {
emit(state.copyWith(status: OperationFilesStatus.loading));
await emit.forEach(
_repository.getOperationFilesStream(currentId),
onData: (List<AttachmentModel> data) => state.copyWith(
status: OperationFilesStatus.success,
remoteFiles: data,
),
onError: (error, stackTrace) => state.copyWith(
status: OperationFilesStatus.failure,
error: error.toString(),
),
);
}
}
void _onAddOperationFiles(
AddOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) async {
final currentId = state.operationId;
// BIVIO 1: PRATICA NUOVA (Nessun ID - salvataggio locale)
if (currentId == null) {
final companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
final newLocalFiles = event.files.map((file) {
return AttachmentModel(
id: null,
companyId: companyId,
operationId: '', // Sarà riempito al salvataggio
name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(),
storagePath: '',
fileSize: file.size,
localBytes: file.bytes,
);
}).toList();
emit(
state.copyWith(
localFiles: [...state.localFiles, ...newLocalFiles],
status: OperationFilesStatus.success,
),
);
return;
}
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID - Upload immediato)
emit(state.copyWith(status: OperationFilesStatus.uploading));
try {
final List<Future<void>> uploadTasks = [];
for (var file in event.files) {
uploadTasks.add(
_repository.uploadAndRegisterOperationFile(
operationId: currentId,
pickedFile: file,
),
);
}
await Future.wait(uploadTasks);
emit(state.copyWith(status: OperationFilesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: e.toString(),
),
);
}
}
FutureOr<void> _onUploadOperationFiles(
UploadOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) async {
if ((event.pickedFiles == null || event.pickedFiles!.isEmpty) &&
(event.photos == null || event.photos!.isEmpty)) {
return;
}
if (state.operationId == null) return;
emit(state.copyWith(status: OperationFilesStatus.uploading));
try {
final List<Future<void>> uploadTasks = [];
// 1. Gestione Documenti normali (PlatformFile)
if (event.pickedFiles != null) {
for (var file in event.pickedFiles!) {
uploadTasks.add(
_repository.uploadAndRegisterOperationFile(
operationId: state.operationId!,
pickedFile: file,
),
);
}
}
// 2. Gestione Foto Fotocamera (XFile)
if (event.photos != null) {
for (var photo in event.photos!) {
// Leggiamo i byte asincronamente
final bytes = await photo.readAsBytes();
final fileSize = await photo.length();
// Lo travestiamo da PlatformFile per passarlo al Repository!
final fakePlatformFile = PlatformFile(
name: photo.name,
size: fileSize,
bytes: bytes,
path: photo.path,
);
uploadTasks.add(
_repository.uploadAndRegisterOperationFile(
operationId: state.operationId!,
pickedFile: fakePlatformFile,
),
);
}
}
// Esecuzione parallela di tutti i documenti e foto
await Future.wait(uploadTasks);
emit(state.copyWith(status: OperationFilesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: e.toString(),
),
);
}
}
FutureOr<void> _onDeleteOperationFiles(
DeleteOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) async {
emit(state.copyWith(status: OperationFilesStatus.loading));
try {
await _repository.deleteOperationFiles(state.selectedFiles);
emit(
state.copyWith(status: OperationFilesStatus.success, selectedFiles: []),
);
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: e.toString(),
),
);
}
}
FutureOr<void> _onToggleOperationFileSelection(
ToggleOperationFileSelectionEvent event,
Emitter<OperationFilesState> emit,
) {
final selectedFiles = List<AttachmentModel>.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file);
} else {
selectedFiles.add(event.file);
}
emit(state.copyWith(selectedFiles: selectedFiles));
}
void _onSelectAllOperationFiles(
SelectAllOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) {
// Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati
emit(state.copyWith(selectedFiles: state.allFiles));
}
void _onClearOperationFileSelection(
ClearOperationFileSelectionEvent event,
Emitter<OperationFilesState> emit,
) {
// Svuotiamo brutalmente la lista
emit(state.copyWith(selectedFiles: []));
}
FutureOr<void> _onLinkFilesToCustomer(
LinkFilesToCustomerEvent event,
Emitter<OperationFilesState> emit,
) async {
if (state.selectedFiles.isEmpty) return;
// BIVIO 1: PRATICA NUOVA (Modalità Locale)
if (state.operationId == null) {
// Mappiamo i file locali: se sono tra quelli selezionati, iniettiamo il customerId
final updatedLocalFiles = state.localFiles.map((file) {
if (state.selectedFiles.contains(file)) {
return file.copyWith(customerId: event.customerId);
}
return file;
}).toList();
emit(
state.copyWith(
localFiles: updatedLocalFiles,
selectedFiles: [], // Svuotiamo la selezione dopo averli associati
status: OperationFilesStatus.success, // o un toast di feedback
),
);
return;
}
// BIVIO 2: PRATICA ESISTENTE (Modalità Remota su DB)
emit(state.copyWith(status: OperationFilesStatus.loading));
try {
final List<Future<void>> linkTasks = [];
for (var file in state.selectedFiles) {
linkTasks.add(
_repository.copyFileToCustomer(
file: file,
customerId: event.customerId,
),
);
}
await Future.wait(linkTasks);
// Svuotiamo la selezione.
// NON serve aggiornare la lista a mano, perché il DB si aggiorna
// e lo Stream di Supabase spingerà automaticamente in UI i file aggiornati!
emit(
state.copyWith(status: OperationFilesStatus.success, selectedFiles: []),
);
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: "Errore associazione: $e",
),
);
}
}
FutureOr<void> _onRenameOperationFile(
RenameOperationFileEvent event,
Emitter<OperationFilesState> emit,
) async {
// BIVIO 1: File Locale (Bozza)
if (event.file.localBytes != null) {
final updatedLocalFiles = state.localFiles.map((f) {
if (f == event.file) {
return f.copyWith(name: event.newName);
}
return f;
}).toList();
emit(state.copyWith(localFiles: updatedLocalFiles));
return;
}
// BIVIO 2: File Remoto (Salvato su DB)
emit(state.copyWith(status: OperationFilesStatus.loading));
try {
await _repository.renameAttachment(event.file.id!, event.newName);
emit(state.copyWith(status: OperationFilesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: "Errore rinomina: $e",
),
);
}
}
FutureOr<void> _onDeleteSpecificOperationFiles(
DeleteSpecificOperationFileEvent event,
Emitter<OperationFilesState> emit,
) {
if (event.file.localBytes != null) {
final updatedLocalFiles = state.localFiles
.where((f) => f != event.file)
.toList();
emit(state.copyWith(localFiles: updatedLocalFiles));
}
}
}

View File

@@ -0,0 +1,81 @@
part of 'operation_files_bloc.dart';
abstract class OperationFilesEvent extends Equatable {
const OperationFilesEvent();
@override
List<Object?> get props => [];
}
class OperationsavedEvent extends OperationFilesEvent {
final String operationId;
const OperationsavedEvent(this.operationId);
@override
List<Object?> get props => [operationId];
}
class LoadOperationFilesEvent extends OperationFilesEvent {
final String? operationId;
final AttachmentModel? operation;
const LoadOperationFilesEvent({this.operationId, this.operation});
@override
List<Object?> get props => [operationId, operation];
}
class AddOperationFilesEvent extends OperationFilesEvent {
final List<PlatformFile> files;
const AddOperationFilesEvent(this.files);
@override
List<Object?> get props => [files];
}
class UploadOperationFilesEvent extends OperationFilesEvent {
final List<PlatformFile>? pickedFiles;
final List<XFile>? photos;
const UploadOperationFilesEvent({this.pickedFiles, this.photos});
@override
List<Object?> get props => [pickedFiles, photos];
}
class LinkFilesToCustomerEvent extends OperationFilesEvent {
final String customerId;
const LinkFilesToCustomerEvent({required this.customerId});
@override
List<Object?> get props => [customerId];
}
class DeleteOperationFilesEvent extends OperationFilesEvent {}
class ToggleOperationFileSelectionEvent extends OperationFilesEvent {
final AttachmentModel file;
const ToggleOperationFileSelectionEvent(this.file);
}
class RenameOperationFileEvent extends OperationFilesEvent {
final AttachmentModel file;
final String newName;
const RenameOperationFileEvent(this.file, this.newName);
@override
List<Object?> get props => [file, newName];
}
class DeleteSpecificOperationFileEvent extends OperationFilesEvent {
final AttachmentModel file;
const DeleteSpecificOperationFileEvent(this.file);
@override
List<Object?> get props => [file];
}
class SelectAllOperationFilesEvent extends OperationFilesEvent {}
class ClearOperationFileSelectionEvent extends OperationFilesEvent {}

View File

@@ -0,0 +1,52 @@
part of 'operation_files_bloc.dart';
enum OperationFilesStatus { initial, loading, uploading, success, failure }
class OperationFilesState extends Equatable {
const OperationFilesState({
this.operationId,
required this.status,
this.error,
this.localFiles = const [],
this.remoteFiles = const [],
this.selectedFiles = const [],
});
final String? operationId;
final OperationFilesStatus status;
final String? error;
final List<AttachmentModel> localFiles;
final List<AttachmentModel> remoteFiles;
final List<AttachmentModel> selectedFiles;
@override
List<Object?> get props => [
operationId,
status,
error,
localFiles,
remoteFiles,
selectedFiles,
];
List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles];
OperationFilesState copyWith({
String? operationId,
OperationFilesStatus? status,
String? error,
List<AttachmentModel>? localFiles,
List<AttachmentModel>? remoteFiles,
List<AttachmentModel>? selectedFiles,
}) {
return OperationFilesState(
operationId: operationId ?? this.operationId,
status: status ?? this.status,
error: error,
localFiles: localFiles ?? this.localFiles,
remoteFiles: remoteFiles ?? this.remoteFiles,
selectedFiles: selectedFiles ?? this.selectedFiles,
);
}
}

View File

@@ -0,0 +1,304 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:get_it/get_it.dart';
import 'package:collection/collection.dart';
import 'package:uuid/uuid.dart';
part 'operations_state.dart';
class OperationsCubit extends Cubit<OperationsState> {
final OperationsRepository _repository = GetIt.I<OperationsRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
final Uuid _uuid = const Uuid(); // Generatore di UUID per il batch
OperationsCubit()
: super(const OperationsState(status: OperationsStatus.initial));
// --- CARICAMENTO E PAGINAZIONE ---
Future<void> loadOperations({bool refresh = false}) async {
if (state.status == OperationsStatus.loading) return;
if (!refresh && state.hasReachedMax) return;
emit(
state.copyWith(
status: OperationsStatus.loading,
errorMessage: null,
allOperations: refresh ? [] : state.allOperations,
hasReachedMax: refresh ? false : state.hasReachedMax,
),
);
try {
final currentOffset = refresh ? 0 : state.allOperations.length;
final companyId = _sessionCubit.state.company?.id;
if (companyId == null) {
throw Exception("Company ID non trovato nella sessione");
}
final newOperations = await _repository.fetchOperations(
companyId: companyId,
offset: currentOffset,
limit: 50,
searchTerm: state.query,
dateRange: state.dateRange,
);
final bool reachedMax = newOperations.length < 50;
emit(
state.copyWith(
status: OperationsStatus.ready,
allOperations: refresh
? newOperations
: [...state.allOperations, ...newOperations],
hasReachedMax: reachedMax,
),
);
} catch (e) {
emit(
state.copyWith(
status: OperationsStatus.failure,
errorMessage: "Errore nel caricamento operazioni: $e",
),
);
}
}
// --- GESTIONE FILTRI ---
void updateFilters({String? query, DateTimeRange? range}) {
emit(
state.copyWith(
query: query ?? state.query,
dateRange: range ?? state.dateRange,
),
);
loadOperations(refresh: true);
}
void clearFilters() {
emit(state.copyWith(query: '', dateRange: null));
loadOperations(refresh: true);
}
void initOperationForm({
OperationModel? existingOperation,
String? operationId,
String? staffId,
String? staffDisplayName,
}) async {
if (existingOperation != null) {
emit(
state.copyWith(
currentOperation: existingOperation,
status: OperationsStatus.ready,
),
);
} else if (operationId != null) {
OperationModel? operationModel = state.allOperations.firstWhereOrNull(
(s) => s.id == operationId,
);
operationModel ??= await _repository.fetchOperationById(operationId);
emit(
state.copyWith(
currentOperation: operationModel,
status: OperationsStatus.ready,
),
);
} else {
// NUOVA PRATICA: Creiamo un nuovo Batch UUID
emit(
state.copyWith(
currentOperation: OperationModel(
storeId: _sessionCubit.state.currentStore?.id ?? '',
reference: '',
createdAt: DateTime.now(),
companyId: _sessionCubit.state.company!.id!,
status: OperationStatus.draft,
batchUuid: _uuid.v4(), // <-- GENERIAMO IL BATCH UNIVOCO
),
status: OperationsStatus.ready,
),
);
}
}
/// MAGIA PURA: Prepara il form per inserire un altro servizio nella stessa pratica.
/// Mantiene il Cliente, il Batch e lo Store, ma svuota il resto.
void prepareNextOperationInBatch() {
if (state.currentOperation == null) return;
final current = state.currentOperation!;
emit(
state.copyWith(
status: OperationsStatus.ready,
currentOperation: OperationModel(
companyId: current.companyId,
storeId: current.storeId,
storeDisplayName: current.storeDisplayName,
batchUuid: current.batchUuid, // <-- MANTIENE IL COLLEGAMENTO
customerId: current.customerId, // <-- MANTIENE IL CLIENTE
customerDisplayName: current.customerDisplayName,
status: OperationStatus.draft,
createdAt: DateTime.now(),
),
),
);
}
// --- PERSISTENZA ---
Future<void> saveCurrentOperation({
required OperationStatus targetStatus,
bool shouldPop = true,
}) async {
if (state.currentOperation == null) return;
emit(state.copyWith(status: OperationsStatus.saving, errorMessage: null));
try {
final operationToSave = state.currentOperation!.copyWith(
status: targetStatus,
);
final updatedOperation = await _repository.saveFullOperation(
operation: operationToSave,
);
emit(
state.copyWith(
// Se non facciamo Pop (es. l'utente vuole aggiungere un altro servizio), non killiamo l'operazione corrente
status: shouldPop
? OperationsStatus.saved
: OperationsStatus.savedNoPop,
currentOperation: shouldPop ? null : updatedOperation,
),
);
// Ricarica in background per la dashboard
loadOperations(refresh: true);
} catch (e) {
emit(
state.copyWith(
status: OperationsStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- RECUPERO OPERAZIONI DELLO STESSO BATCH (Per UI di riepilogo) ---
/// Puoi usare questa funzione se nella UI vuoi mostrare "Hai inserito 3 servizi in questa pratica"
List<OperationModel> getOperationsInCurrentBatch() {
if (state.currentOperation == null) return [];
final currentBatch = state.currentOperation!.batchUuid;
// Filtriamo dalla lista caricata (o potresti fare una query diretta a Supabase se preferisci)
return state.allOperations
.where(
(op) =>
op.batchUuid == currentBatch &&
op.id != state.currentOperation!.id,
)
.toList();
}
// --- GESTIONE DELLO STATO DEL FORM IN TEMPO REALE ---
void updateOperationFields({
String? customerId,
String? customerDisplayName,
String? type,
String? providerId,
String? providerDisplayName,
String? subtype,
String? description,
DateTime? expirationDate,
int? quantity,
String? modelId,
String? modelDisplayName,
String? staffId,
String? staffDisplayName,
// Aggiungiamo questi flag per forzare la pulizia dei campi quando cambi tipo
bool clearProvider = false,
bool clearType = false,
bool clearSubtype = false,
bool clearDescription = false,
bool clearExpiration = false,
bool clearQuantity = false,
bool clearModel = false,
}) {
if (state.currentOperation == null) return;
final current = state.currentOperation!;
// Creiamo il modello aggiornato
// ATTENZIONE: adatta questa logica in base a come è scritto il tuo copyWith!
int? newQuantity;
if (clearQuantity) {
newQuantity = 1;
}
if (quantity != null && quantity <= 0) {
newQuantity = 0;
}
if (quantity != null && quantity > 0) {
newQuantity = quantity;
}
final updated = current.copyWith(
customerId: customerId,
customerDisplayName: customerDisplayName,
// Se clearProvider è true, forziamo una stringa vuota (o null se il tuo modello lo supporta)
providerId: clearProvider ? null : (providerId ?? current.providerId),
providerDisplayName: clearProvider
? null
: (providerDisplayName ?? current.providerDisplayName),
quantity: newQuantity,
type: clearType ? null : (type ?? current.type),
description: clearDescription
? null
: (description ?? current.description),
subtype: clearSubtype ? null : (subtype ?? current.subtype),
expirationDate: clearExpiration
? null
: (expirationDate ?? current.expirationDate),
modelId: clearModel ? null : (modelId ?? current.modelId),
modelDisplayName: clearModel
? null
: (modelDisplayName ?? current.modelDisplayName),
staffId: staffId ?? current.staffId,
staffDisplayName: staffDisplayName ?? current.staffDisplayName,
);
emit(state.copyWith(currentOperation: updated));
}
// Metodo di utilità per calcolare la data X mesi da oggi
DateTime _calculateMonths(int months) {
final now = DateTime.now();
return DateTime(now.year, now.month + months, now.day);
}
// Quando l'utente seleziona un tipo, impostiamo il default
void setTypeWithSmartDefault(String type) {
DateTime? defaultDate;
if (type == 'Energy') defaultDate = _calculateMonths(24);
if (type == 'Fin') defaultDate = _calculateMonths(30);
if (type == 'Entertainment') defaultDate = _calculateMonths(12);
updateOperationFields(
type: type,
expirationDate: defaultDate,
clearProvider: true,
clearSubtype: true,
clearModel: true,
clearQuantity: true,
);
}
}

View File

@@ -1,6 +1,6 @@
part of 'services_cubit.dart'; part of 'operations_cubit.dart';
enum ServicesStatus { enum OperationsStatus {
initial, initial,
loading, loading,
ready, ready,
@@ -11,20 +11,20 @@ enum ServicesStatus {
failure, failure,
} }
class ServicesState extends Equatable { class OperationsState extends Equatable {
final ServicesStatus status; final OperationsStatus status;
final List<ServiceModel> allServices; final List<OperationModel> allOperations;
final ServiceModel? currentService; // La bozza che stiamo editando final OperationModel? currentOperation; // La bozza che stiamo editando
final String? errorMessage; final String? errorMessage;
final String query; final String query;
final DateTimeRange? dateRange; final DateTimeRange? dateRange;
final bool hasReachedMax; final bool hasReachedMax;
final bool isSavingDraft; final bool isSavingDraft;
const ServicesState({ const OperationsState({
required this.status, required this.status,
this.allServices = const [], this.allOperations = const [],
this.currentService, this.currentOperation,
this.errorMessage, this.errorMessage,
this.query = '', this.query = '',
this.dateRange, this.dateRange,
@@ -32,20 +32,20 @@ class ServicesState extends Equatable {
this.isSavingDraft = false, this.isSavingDraft = false,
}); });
ServicesState copyWith({ OperationsState copyWith({
ServicesStatus? status, OperationsStatus? status,
List<ServiceModel>? allServices, List<OperationModel>? allOperations,
ServiceModel? currentService, OperationModel? currentOperation,
String? errorMessage, String? errorMessage,
String? query, String? query,
DateTimeRange? dateRange, DateTimeRange? dateRange,
bool? hasReachedMax, bool? hasReachedMax,
bool? isSavingDraft, bool? isSavingDraft,
}) { }) {
return ServicesState( return OperationsState(
status: status ?? this.status, status: status ?? this.status,
allServices: allServices ?? this.allServices, allOperations: allOperations ?? this.allOperations,
currentService: currentService ?? this.currentService, currentOperation: currentOperation ?? this.currentOperation,
errorMessage: errorMessage, errorMessage: errorMessage,
query: query ?? this.query, query: query ?? this.query,
dateRange: dateRange ?? this.dateRange, dateRange: dateRange ?? this.dateRange,
@@ -57,8 +57,8 @@ class ServicesState extends Equatable {
@override @override
List<Object?> get props => [ List<Object?> get props => [
status, status,
allServices, allOperations,
currentService, currentOperation,
errorMessage, errorMessage,
query, query,
dateRange, dateRange,

View File

@@ -0,0 +1,305 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/operation_model.dart';
class OperationsRepository {
final _supabase = Supabase.instance.client;
final companyId = GetIt.I.get<SessionCubit>().state.company!.id;
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
Future<OperationModel> fetchOperationById(String id) async {
try {
final response = await _supabase
.from('operation')
.select('''
*,
customer(name),
store(name),
staff_member(name),
provider(name),
model(name_with_brand),
attachment(*)
''')
.eq('id', id)
.single();
return OperationModel.fromMap(response);
} catch (e) {
throw Exception('Errore nel caricamento del servizio: $e');
}
}
// --- RECUPERO PAGINATO CON FILTRI E JOIN ---
Future<List<OperationModel>> fetchOperations({
required String companyId,
required int offset,
int limit = 50,
String? searchTerm,
DateTimeRange? dateRange,
}) async {
try {
var query = _supabase
.from('operation')
.select('''
*,
customer(name),
store(name),
provider(name),
model(name_with_brand),
staff_member(name),
attachment(*)
''')
.eq('company_id', companyId);
// Filtro Range Date
if (dateRange != null) {
query = query
.gte('created_at', dateRange.start.toIso8601String())
.lte('created_at', dateRange.end.toIso8601String());
}
if (searchTerm != null && searchTerm.isNotEmpty) {
// Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or(
'reference.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%',
);
}
final response = await query
.order('created_at', ascending: false)
.range(offset, offset + limit - 1);
return (response as List)
.map((map) => OperationModel.fromMap(map))
.toList();
} catch (e) {
throw Exception('$e');
}
}
Stream<List<OperationModel>> getLastStoreOperationsStream({
required String storeId,
required int limit,
}) {
return _supabase
.from('operation')
.stream(primaryKey: ['id'])
.eq('store_id', storeId)
.order('created_at', ascending: false)
.limit(limit)
.map(
(listOfMaps) =>
listOfMaps.map((map) => OperationModel.fromMap(map)).toList(),
);
}
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
Future<OperationModel> saveFullOperation({
required OperationModel operation,
}) async {
try {
// 1. Salvataggio classico dell'operazione corrente
final response = await _supabase
.from('operation')
.upsert(operation.toMap())
.select(
'*, provider(*), model(*), store(*), staff_member(*), customer(*), attachment(*)',
)
.single();
final savedOperation = OperationModel.fromMap(response);
// 2. ALLINEAMENTO BATCH SEMPRE ATTIVO!
if (operation.batchUuid.isNotEmpty) {
await _supabase
.from('operation')
.update({'note': operation.note}) // Spalmiamo la nota attuale
.eq(
'batch_uuid',
operation.batchUuid,
); // Su tutte le pratiche di questo scontrino
}
return savedOperation;
} catch (e) {
throw Exception("Errore nel salvataggio dell'operazione: $e");
}
}
// --- ELIMINAZIONE ---
Future<void> deleteOperation(String id) async {
try {
await _supabase.from('operation').delete().eq('id', id);
} catch (e) {
throw Exception('$e');
}
}
// --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE ---
Future<List<String>> fetchTopEntertainmentTypes(String companyId) async {
try {
// Cerchiamo i tipi più frequenti associati ai servizi di questa company
// Nota: dobbiamo passare attraverso la tabella 'operation' per filtrare per company_id
final response = await _supabase
.from('operation')
.select('description')
.eq('company_id', companyId)
.eq('type', 'Entertainment')
.limit(50); // Prendiamo un campione
// Logica rapida per contare le occorrenze e prendere i primi 5
final Map<String, int> counts = {};
for (var item in (response as List)) {
final description = item['description'] as String;
counts[description] = (counts[description] ?? 0) + 1;
}
var sortedKeys = counts.keys.toList()
..sort((a, b) => counts[b]!.compareTo(counts[a]!));
return sortedKeys.take(5).toList();
} catch (e) {
return [
"Netflix",
"DAZN",
"Disney+",
"Sky",
]; // Fallback se non c'è ancora storia
}
}
/// Ascolta in tempo reale i file caricati per una pratica
Stream<List<AttachmentModel>> getOperationFilesStream(String operationId) {
return _supabase
.from('attachment')
.stream(primaryKey: ['id'])
.eq('operation_id', operationId)
.order('created_at', ascending: false)
.map(
(listOfMaps) =>
listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
);
}
Future<AttachmentModel> uploadAndRegisterOperationFile({
required String operationId,
required PlatformFile pickedFile,
}) async {
final cleanFileName = pickedFile.name.replaceAll(
RegExp(r'[^a-zA-Z0-9\.\-]'),
'_',
);
final storagePath =
'$companyId/operations/$operationId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
final int fileSize = pickedFile.size;
final fileToSave = AttachmentModel(
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
operationId: operationId,
name: cleanFileName.fileNameWithoutExtension(),
extension: cleanFileName.fileExtension(),
storagePath: storagePath,
fileSize: fileSize,
);
final String mimeType = fileToSave.extension.toLowerCase() == 'pdf'
? 'application/pdf'
: 'image/${fileToSave.extension}';
try {
// Usiamo bytes invece del path per massima compatibilità
if (pickedFile.bytes == null && pickedFile.path == null) {
throw 'Impossibile leggere il contenuto del file';
}
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
if (pickedFile.bytes != null) {
await _supabase.storage
.from('documents')
.uploadBinary(
storagePath,
pickedFile.bytes!,
fileOptions: FileOptions(contentType: mimeType, upsert: true),
);
}
final response = await _supabase
.from('attachment')
.insert(fileToSave.toMap())
.select()
.single();
return AttachmentModel.fromMap(response);
} catch (e) {
throw 'Errore durante l\'upload: $e';
}
}
Future<void> copyFileToCustomer({
required AttachmentModel file,
required String customerId,
}) async {
await _supabase
.from('attachment')
.update({'customer_id': customerId})
.eq('id', file.id!);
}
Future<void> renameAttachment(String id, String newName) async {
try {
await _supabase.from('attachment').update({'name': newName}).eq('id', id);
} catch (e) {
throw '$e';
}
}
Future<void> deleteSpecificOperationFile(AttachmentModel file) async {
try {
if (file.customerId == null) {
await _supabase.from('attachment').delete().eq('id', file.id!);
await _supabase.storage.from('documents').remove([file.storagePath!]);
} else {
await _supabase
.from('attachment')
.update({'operation_id': null})
.eq('id', file.id!);
}
} catch (e) {
throw '$e';
}
}
Future<void> deleteOperationFiles(List<AttachmentModel> files) async {
if (files.isEmpty) return;
// 1. Prepariamo le liste di ID e di Percorsi
final List<String> idsToDelete = [];
final List<String> idsToEdit = [];
final List<String> storagePathsToDelete = [];
for (var file in files) {
if (file.customerId == null) {
idsToDelete.add(file.id!);
storagePathsToDelete.add(file.storagePath!);
} else {
idsToEdit.add(file.id!);
}
}
try {
if (idsToDelete.isNotEmpty) {
await _supabase.from('attachment').delete().inFilter('id', idsToDelete);
await _supabase.storage.from('documents').remove(storagePathsToDelete);
}
if (idsToEdit.isNotEmpty) {
await _supabase
.from('attachment')
.update({'operation_id': null})
.inFilter('id', idsToEdit);
}
} on PostgrestException catch (e) {
throw 'Errore database: ${e.message}';
} catch (e) {
throw 'Errore durante l\'eliminazione dei file: $e';
}
}
}

View File

@@ -0,0 +1,248 @@
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
enum OperationStatus {
ok('ok'),
waitingforaction('waiting_for_action'),
waitingforsupport('waiting_for_support'),
waitingfordeployment('waiting_for_deployment'),
ko('ko'),
draft('draft'),
canceled('canceled');
static OperationStatus fromString(String value) {
final normalizedValue = value.replaceAll('_', '').toLowerCase();
return OperationStatus.values.firstWhere(
(e) => e.name.toLowerCase() == normalizedValue,
);
}
final String supabaseName;
const OperationStatus(this.supabaseName);
}
class OperationModel extends Equatable {
final String? id;
final DateTime? createdAt;
final String type;
final String? subtype;
final String? providerId;
final String? providerDisplayName;
final String? modelId;
final String? modelDisplayName;
final String? description;
final DateTime? expirationDate;
final String note;
final bool showInDashboard;
final String batchUuid;
final String companyId;
final String storeId;
final String? storeDisplayName;
final int quantity;
final String? staffId;
final String? staffDisplayName;
final String? lastCampaignId;
final OperationStatus status;
final String? customerId;
final String? customerDisplayName;
final String reference;
// ALLEGATI (Aggiunto)
final List<AttachmentModel> attachments;
const OperationModel({
this.id,
this.createdAt,
this.type = '',
this.subtype,
this.providerId,
this.providerDisplayName,
this.modelId,
this.modelDisplayName,
this.description,
this.expirationDate,
this.note = '',
this.showInDashboard = true,
this.batchUuid = '',
required this.companyId,
this.storeId = '',
this.storeDisplayName,
this.quantity = 1,
this.staffId,
this.staffDisplayName,
this.lastCampaignId,
this.status = OperationStatus.draft,
this.customerId,
this.customerDisplayName,
this.reference = '',
this.attachments = const [],
});
OperationModel copyWith({
String? id,
DateTime? createdAt,
String? type,
String? subtype,
String? providerId,
String? providerDisplayName,
String? modelId,
String? modelDisplayName,
String? description,
DateTime? expirationDate,
String? note,
bool? showInDashboard,
String? batchUuid,
String? companyId,
String? storeId,
String? storeDisplayName,
int? quantity,
String? staffId,
String? staffDisplayName,
String? lastCampaignId,
OperationStatus? status,
String? customerId,
String? customerDisplayName,
String? reference,
List<AttachmentModel>? attachments,
}) => OperationModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
type: type ?? this.type,
subtype: subtype ?? this.subtype,
providerId: providerId ?? this.providerId,
providerDisplayName: providerDisplayName ?? this.providerDisplayName,
modelId: modelId ?? this.modelId,
modelDisplayName: modelDisplayName ?? this.modelDisplayName,
description: description ?? this.description,
expirationDate: expirationDate ?? this.expirationDate,
note: note ?? this.note,
showInDashboard: showInDashboard ?? this.showInDashboard,
batchUuid: batchUuid ?? this.batchUuid,
companyId: companyId ?? this.companyId,
storeId: storeId ?? this.storeId,
storeDisplayName: storeDisplayName ?? this.storeDisplayName,
quantity: quantity ?? this.quantity,
staffId: staffId ?? this.staffId,
staffDisplayName: staffDisplayName ?? this.staffDisplayName,
lastCampaignId: lastCampaignId ?? this.lastCampaignId,
status: status ?? this.status,
customerId: customerId ?? this.customerId,
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
reference: reference ?? this.reference,
attachments: attachments ?? this.attachments,
);
@override
List<Object?> get props => [
id,
createdAt,
type,
subtype,
providerId,
providerDisplayName,
modelId,
modelDisplayName,
description,
expirationDate,
note,
showInDashboard,
batchUuid,
companyId,
storeId,
storeDisplayName,
quantity,
staffId,
staffDisplayName,
lastCampaignId,
status,
customerId,
customerDisplayName,
reference,
attachments,
];
factory OperationModel.empty({required String companyId}) {
return OperationModel(id: null, createdAt: null, companyId: companyId);
}
factory OperationModel.fromMap(Map<String, dynamic> map) {
return OperationModel(
id: map['id'] as String?,
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
type: map['type'] as String? ?? '',
subtype: map['sub_type'] as String?,
// I campi relazionali nullabili restano rigorosamente null!
providerId: map['provider_id'] as String?,
// MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti
providerDisplayName: (map['provider']?['name'] as String?)?.myFormat(),
modelId: map['model_id'] as String?,
modelDisplayName: (map['model']?['name_with_brand'] as String?)
?.myFormat(),
description: map['description'] as String?,
expirationDate: map['expiration_date'] != null
? DateTime.parse(map['expiration_date'])
: null,
note: map['note'] as String? ?? '',
showInDashboard: map['show_in_dashboard'] as bool? ?? true,
batchUuid: map['batch_uuid'] as String? ?? '',
companyId: map['company_id'] as String,
storeId:
map['store_id'] as String? ??
'', // Questo è non-nullable nella tua classe
storeDisplayName: (map['store']?['name'] as String?)?.myFormat(),
quantity: map['quantity'] is int
? map['quantity']
: int.tryParse(map['quantity']?.toString() ?? '1') ?? 1,
staffId: map['staff_id'] as String?,
staffDisplayName: (map['staff_member']?['name'] as String?)?.myFormat(),
lastCampaignId: map['last_campaign_id'] as String?,
status: OperationStatus.fromString(map['status'] ?? 'draft'),
customerId: map['customer_id'] as String?,
customerDisplayName: (map['customer']?['name'] as String?)?.myFormat(),
attachments:
(map['attachment'] as List?)
?.map((x) => AttachmentModel.fromMap(x))
.toList() ??
const [],
reference: map['reference'] as String? ?? '',
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'type': type,
'sub_type': subtype,
'provider_id': providerId,
'model_id': modelId,
'description': description,
if (expirationDate != null)
'expiration_date': expirationDate!.toIso8601String(),
'note': note,
'show_in_dashboard': showInDashboard,
'batch_uuid': batchUuid,
'company_id': companyId,
'store_id': storeId,
'quantity': quantity,
if (staffId != null) 'staff_id': staffId,
if (lastCampaignId != null) 'last_campaign_id': lastCampaignId,
'status': status.supabaseName,
if (customerId != null) 'customer_id': customerId,
'reference': reference,
};
}
}

View File

@@ -1,12 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ServiceActionCard extends StatelessWidget { class OperationActionCard extends StatelessWidget {
final String title; final String title;
final IconData icon; final IconData icon;
final VoidCallback onTap; final VoidCallback onTap;
final Color color; final Color color;
final int count; final int count;
const ServiceActionCard({ const OperationActionCard({
super.key, super.key,
required this.title, required this.title,
required this.icon, required this.icon,

View File

@@ -0,0 +1,481 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/operations/ui/widgets/customer_section.dart';
import 'package:flux/features/operations/ui/widgets/details_section.dart';
import 'package:flux/features/operations/ui/widgets/operation_files_section.dart';
import 'package:flux/features/operations/ui/widgets/staff_section.dart';
import 'package:get_it/get_it.dart'; // ASSICURATI DEL PATH
// import 'package:flux/features/attachments/ui/operation_files_section.dart';
class OperationFormScreen extends StatefulWidget {
final String? operationId;
final OperationModel? existingOperation;
const OperationFormScreen({
super.key,
this.operationId,
this.existingOperation,
});
@override
State<OperationFormScreen> createState() => _OperationFormScreenState();
}
class _OperationFormScreenState extends State<OperationFormScreen> {
final _formKey = GlobalKey<FormState>();
final _referenceController = TextEditingController();
final _noteController = TextEditingController();
final _freeTextSubtypeController = TextEditingController();
final _freeTextDescriptionController = TextEditingController();
final List<String> _availableTypes = [
'AL',
'MNP',
'NIP',
'UNICA',
'TELEPASS',
'Energy',
'Fin',
'Entertainment',
'Custom',
];
bool _isInitialized = false;
@override
void initState() {
super.initState();
final cubit = context.read<OperationsCubit>();
final currentLoggedStaff = GetIt.I
.get<SessionCubit>()
.state
.currentStaffMember!;
// 1. Diciamo al Cubit di prepararsi
cubit.initOperationForm(
existingOperation: widget.existingOperation,
operationId: widget.operationId,
staffId: currentLoggedStaff.id,
staffDisplayName: currentLoggedStaff.name,
);
// 2. IL TRUCCO MAGICO:
// Se abbiamo passato existingOperation, il Cubit si è appena aggiornato.
// Lo stato è già pronto, quindi sincronizziamo i controller SUBITO!
if (cubit.state.currentOperation != null) {
_syncTextControllers(cubit.state.currentOperation!);
}
}
@override
void dispose() {
_referenceController.dispose();
_noteController.dispose();
_freeTextSubtypeController.dispose();
super.dispose();
}
void _syncTextControllers(OperationModel model) {
if (_referenceController.text.isEmpty && model.reference.isNotEmpty) {
_referenceController.text = model.reference;
}
if (_noteController.text.isEmpty && model.note.isNotEmpty) {
_noteController.text = model.note;
}
if (_freeTextSubtypeController.text.isEmpty &&
model.subtype != null &&
model.subtype!.isNotEmpty) {
_freeTextSubtypeController.text = model.subtype!;
}
if (_freeTextDescriptionController.text.isEmpty &&
model.description != null &&
model.description!.isNotEmpty) {
_freeTextDescriptionController.text = model.description!;
}
_isInitialized = true;
}
void _saveOperation({required bool keepAdding}) {
if (_formKey.currentState!.validate()) {
final cubit = context.read<OperationsCubit>();
final currentOperation = cubit.state.currentOperation!;
final operationToSave = currentOperation.copyWith(
reference: _referenceController.text,
note: _noteController.text,
subtype: ['Entertainment', 'Custom'].contains(currentOperation.type)
? _freeTextSubtypeController.text
: currentOperation.subtype,
description: ['Energy', 'Custom'].contains(currentOperation.type)
? _freeTextDescriptionController.text
: currentOperation.description,
);
cubit.initOperationForm(existingOperation: operationToSave);
cubit.saveCurrentOperation(
targetStatus: OperationStatus.ok,
shouldPop: !keepAdding,
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocConsumer<OperationsCubit, OperationsState>(
listenWhen: (previous, current) =>
previous.status != current.status ||
previous.currentOperation?.id != current.currentOperation?.id,
listener: (context, state) {
if (state.status == OperationsStatus.ready &&
state.currentOperation != null &&
!_isInitialized) {
_syncTextControllers(state.currentOperation!);
}
if (state.status == OperationsStatus.saved) {
Navigator.of(context).pop();
} else if (state.status == OperationsStatus.savedNoPop) {
context.read<OperationsCubit>().prepareNextOperationInBatch();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Servizio aggiunto! Inserisci il prossimo.'),
),
);
_freeTextSubtypeController.clear();
_freeTextDescriptionController.clear();
} else if (state.status == OperationsStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Errore'),
backgroundColor: theme.colorScheme.error,
),
);
}
},
builder: (context, state) {
if (!_isInitialized &&
(widget.operationId != null || widget.existingOperation != null) &&
state.status == OperationsStatus.loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: Text(
state.currentOperation?.id == null
? 'Nuova Pratica'
: 'Modifica Pratica',
),
),
body: Form(
key: _formKey,
child: LayoutBuilder(
builder: (context, constraints) {
final isUltraWide = constraints.maxWidth > 1400;
final isDesktop = constraints.maxWidth > 900;
if (isUltraWide) {
// --- LAYOUT 3 COLONNE (Schermi giganti) ---
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. FORM PRINCIPALE (40%)
Expanded(
flex: 4,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
// Attenzione: devi togliere la sezione file dal _buildMainFormContent!
child: _buildMainFormContent(
theme,
state,
showFiles: false,
),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
// 2. NOTE (30%)
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildNotesSection(isDesktop: true),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
// 3. FILE (30%)
Expanded(
flex: 3,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: OperationFilesSection(
currentOp: state.currentOperation!,
),
),
),
],
);
} else if (isDesktop) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 7,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: _buildMainFormContent(theme, state),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildNotesSection(isDesktop: true),
),
),
],
);
} else {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMainFormContent(theme, state),
const Divider(height: 32),
_buildNotesSection(isDesktop: false),
const SizedBox(height: 80),
],
),
);
}
},
),
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
flex: 1,
child: OutlinedButton(
onPressed: state.status == OperationsStatus.saving
? null
: () => _saveOperation(keepAdding: true),
child: const Text(
'Salva e Aggiungi Altro',
textAlign: TextAlign.center,
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: ElevatedButton(
onPressed: state.status == OperationsStatus.saving
? null
: () => _saveOperation(keepAdding: false),
child: state.status == OperationsStatus.saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text('Salva ed Esci'),
),
),
],
),
),
),
);
},
);
}
Widget _buildMainFormContent(
ThemeData theme,
OperationsState state, {
bool showFiles = true,
}) {
final currentOp = state.currentOperation;
final currentType = currentOp?.type ?? 'AL';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StaffSection(currentOp: currentOp),
const Divider(height: 50),
_buildSectionTitle('Cliente & Riferimento'),
CustomerSection(currentOp: currentOp),
const SizedBox(height: 16),
TextFormField(
controller: _referenceController,
decoration: const InputDecoration(
labelText: 'Riferimento (es. numero di telefono, targa...)',
prefixIcon: Icon(Icons.tag),
),
),
const Divider(height: 32),
_buildSectionTitle('Cosa stiamo facendo?'),
Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: _availableTypes.map((type) {
return ChoiceChip(
label: Text(type),
selected: currentType == type,
onSelected: (selected) {
if (selected) {
context.read<OperationsCubit>().setTypeWithSmartDefault(type);
}
},
);
}).toList(),
),
const Divider(height: 32),
_buildSectionTitle('Dettagli Servizio'),
DetailsSection(
currentOp: currentOp,
currentType: currentType,
freeTextSubtypeController: _freeTextSubtypeController,
freeTextDescriptionController: _freeTextDescriptionController,
durationQuickPicks: _buildDurationQuickPicks(currentOp),
),
// QUANTITÀ
Row(
children: [
const Text('Quantità: '),
IconButton(
icon: const Icon(Icons.remove),
onPressed: () {
final q = currentOp?.quantity ?? 1;
if (q > 1) {
context.read<OperationsCubit>().updateOperationFields(
quantity: q - 1,
);
}
},
),
Text(
'${currentOp?.quantity ?? 1}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
final q = currentOp?.quantity ?? 1;
context.read<OperationsCubit>().updateOperationFields(
quantity: q + 1,
);
},
),
],
),
const Divider(height: 32),
if (showFiles) ...[OperationFilesSection(currentOp: currentOp!)],
],
);
}
Widget _buildDurationQuickPicks(OperationModel? currentOp) {
final durations = [3, 6, 12, 24, 30, 36, 48];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Imposta durata rapida (mesi):",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: durations.map((months) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ActionChip(
label: Text("$months m"),
backgroundColor: Colors.blue.withValues(alpha: 0.05),
onPressed: () {
final now = DateTime.now();
context.read<OperationsCubit>().updateOperationFields(
expirationDate: DateTime(
now.year,
now.month + months,
now.day,
),
);
},
),
);
}).toList(),
),
),
],
);
}
Widget _buildNotesSection({required bool isDesktop}) {
final title = _buildSectionTitle('Note Interne');
final noteField = TextFormField(
controller: _noteController,
keyboardType: TextInputType.multiline,
minLines: isDesktop ? null : 5,
maxLines: null,
expands: isDesktop,
textAlignVertical: TextAlignVertical.top,
decoration: const InputDecoration(
hintText: 'Incolla qui seriali, ICCID, IBAN, indirizzi...',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
);
return isDesktop
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title,
const SizedBox(height: 8),
Expanded(child: noteField),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [title, const SizedBox(height: 8), noteField],
);
}
Widget _buildSectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
title,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
);
}
}

View File

@@ -1,26 +1,27 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flux/features/services/blocs/service_files_bloc.dart';
class ServiceMobileUploadScreen extends StatefulWidget { class OperationMobileUploadScreen extends StatefulWidget {
final String serviceId; final String operationId;
final String serviceName; final String operationName;
const ServiceMobileUploadScreen({ const OperationMobileUploadScreen({
super.key, super.key,
required this.serviceId, required this.operationId,
required this.serviceName, required this.operationName,
}); });
@override @override
State<ServiceMobileUploadScreen> createState() => State<OperationMobileUploadScreen> createState() =>
_ServiceMobileUploadScreenState(); _OperationMobileUploadScreenState();
} }
class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> { class _OperationMobileUploadScreenState
extends State<OperationMobileUploadScreen> {
// 1. LA NOSTRA STAGING AREA (Il "Carrello") // 1. LA NOSTRA STAGING AREA (Il "Carrello")
final List<PlatformFile> _stagedFiles = []; final List<PlatformFile> _stagedFiles = [];
@@ -35,10 +36,10 @@ class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<ServiceFilesBloc, ServiceFilesState>( return BlocListener<OperationFilesBloc, OperationFilesState>(
listener: (context, state) { listener: (context, state) {
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina! // Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
if (state.status == ServiceFilesStatus.success && _isUploading) { if (state.status == OperationFilesStatus.success && _isUploading) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text("Tutti i file caricati con successo! ✅"), content: Text("Tutti i file caricati con successo! ✅"),
@@ -46,7 +47,7 @@ class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
); );
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
if (state.status == ServiceFilesStatus.failure) { if (state.status == OperationFilesStatus.failure) {
setState(() => _isUploading = false); setState(() => _isUploading = false);
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
@@ -55,7 +56,7 @@ class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
}, },
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("Upload Pratica:\n${widget.serviceName}"), title: Text("Upload Pratica:\n${widget.operationName}"),
automaticallyImplyLeading: !_isUploading, automaticallyImplyLeading: !_isUploading,
), ),
body: Stack( body: Stack(
@@ -294,8 +295,8 @@ class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
// Diciamo al BLoC di caricare tutti i file. // Diciamo al BLoC di caricare tutti i file.
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda) // Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
final bloc = context.read<ServiceFilesBloc>(); final bloc = context.read<OperationFilesBloc>();
bloc.add(UploadMultipleServiceFilesEvent(_stagedFiles)); bloc.add(UploadOperationFilesEvent(pickedFiles: _stagedFiles));
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"! // N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
} }

View File

@@ -1,19 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/services/models/service_model.dart'; import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/services/utils/service_actions.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
// Importa i tuoi modelli e cubit // Importa i tuoi modelli e cubit
class ServicesScreen extends StatefulWidget { class OperationsScreen extends StatefulWidget {
const ServicesScreen({super.key}); const OperationsScreen({super.key});
@override @override
State<ServicesScreen> createState() => _ServicesScreenState(); State<OperationsScreen> createState() => _OperationsScreenState();
} }
class _ServicesScreenState extends State<ServicesScreen> { class _OperationsScreenState extends State<OperationsScreen> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@override @override
@@ -22,12 +21,12 @@ class _ServicesScreenState extends State<ServicesScreen> {
// Agganciamo il listener per la paginazione (Scroll Infinito) // Agganciamo il listener per la paginazione (Scroll Infinito)
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
// Carichiamo i servizi iniziali // Carichiamo i servizi iniziali
context.read<ServicesCubit>().loadServices(); context.read<OperationsCubit>().loadOperations();
} }
void _onScroll() { void _onScroll() {
if (_isBottom) { if (_isBottom) {
context.read<ServicesCubit>().loadServices(); context.read<OperationsCubit>().loadOperations();
} }
} }
@@ -60,16 +59,16 @@ class _ServicesScreenState extends State<ServicesScreen> {
), ),
], ],
), ),
body: BlocBuilder<ServicesCubit, ServicesState>( body: BlocBuilder<OperationsCubit, OperationsState>(
builder: (context, state) { builder: (context, state) {
// 1. Stato di caricamento iniziale // 1. Stato di caricamento iniziale
if (state.status == ServicesStatus.loading && if (state.status == OperationsStatus.loading &&
state.allServices.isEmpty) { state.allOperations.isEmpty) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
// 2. Lista vuota // 2. Lista vuota
if (state.allServices.isEmpty) { if (state.allOperations.isEmpty) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -77,9 +76,9 @@ class _ServicesScreenState extends State<ServicesScreen> {
const Text("Nessuna pratica trovata."), const Text("Nessuna pratica trovata."),
const SizedBox(height: 10), const SizedBox(height: 10),
ElevatedButton( ElevatedButton(
onPressed: () => context.read<ServicesCubit>().loadServices( onPressed: () => context
refresh: true, .read<OperationsCubit>()
), .loadOperations(refresh: true),
child: const Text("Riprova"), child: const Text("Riprova"),
), ),
], ],
@@ -90,15 +89,15 @@ class _ServicesScreenState extends State<ServicesScreen> {
// 3. La Lista (con Pull-to-refresh) // 3. La Lista (con Pull-to-refresh)
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => onRefresh: () =>
context.read<ServicesCubit>().loadServices(refresh: true), context.read<OperationsCubit>().loadOperations(refresh: true),
child: ListView.builder( child: ListView.builder(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB
itemCount: state.hasReachedMax itemCount: state.hasReachedMax
? state.allServices.length ? state.allOperations.length
: state.allServices.length + 1, : state.allOperations.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= state.allServices.length) { if (index >= state.allOperations.length) {
return const Center( return const Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
@@ -107,21 +106,21 @@ class _ServicesScreenState extends State<ServicesScreen> {
); );
} }
final service = state.allServices[index]; final operation = state.allOperations[index];
return _buildServiceCard(context, service); return _buildOperationCard(context, operation);
}, },
), ),
); );
}, },
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => startNewService(context), onPressed: () => startNewOperation(context),
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
); );
} }
Widget _buildServiceCard(BuildContext context, ServiceModel service) { Widget _buildOperationCard(BuildContext context, OperationModel operation) {
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
elevation: 2, elevation: 2,
@@ -132,22 +131,13 @@ class _ServicesScreenState extends State<ServicesScreen> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
service.customerDisplayName ?? "Cliente sconosciuto", operation.customerDisplayName ?? "Cliente sconosciuto",
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 16,
), ),
), ),
), ),
if (service.isBozza)
const Chip(
label: Text(
"BOZZA",
style: TextStyle(fontSize: 10, color: Colors.white),
),
backgroundColor: Colors.orange,
visualDensity: VisualDensity.compact,
),
], ],
), ),
subtitle: Column( subtitle: Column(
@@ -155,52 +145,56 @@ class _ServicesScreenState extends State<ServicesScreen> {
children: [ children: [
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
"Pratica: ${service.number}${service.createdAt?.day}/${service.createdAt?.month}/${service.createdAt?.year}", "Pratica: ${operation.reference}${operation.createdAt?.day}/${operation.createdAt?.month}/${operation.createdAt?.year}",
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// I nostri mini-chip per i servizi attivati Row(
Wrap(
spacing: 6,
children: [ children: [
if (service.al > 0 || service.mnp > 0) Text(operation.type),
_miniBadge("📞 Tel", Colors.blue), const SizedBox(width: 8),
if (service.energyServices.isNotEmpty) _buildOperationStatus(operation.status),
_miniBadge("⚡ Energy", Colors.green),
if (service.finServices.isNotEmpty)
_miniBadge("💰 Fin", Colors.purple),
if (service.entertainmentServices.isNotEmpty)
_miniBadge("📺 Ent", Colors.red),
], ],
), ),
], ],
), ),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => context.pushNamed( onTap: () => context.pushNamed(
'service-form', 'operation-form',
extra: service, // <-- LA MAGIA È QUI: Passa l'oggetto intero! extra: operation, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
// Teniamo anche il parametro URL per coerenza di routing // Teniamo anche il parametro URL per coerenza di routing
queryParameters: service.id != null ? {'serviceId': service.id!} : {}, queryParameters: operation.id != null
? {'operationId': operation.id!}
: {},
), ),
), ),
); );
} }
Widget _miniBadge(String text, Color color) { Widget _buildOperationStatus(OperationStatus status) {
return Container( Color color;
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), switch (status) {
decoration: BoxDecoration( case OperationStatus.canceled || OperationStatus.ko:
color: color.withValues(alpha: 0.1), color = Colors.grey.shade800;
borderRadius: BorderRadius.circular(4), break;
border: Border.all(color: color.withValues(alpha: 0.5)), case OperationStatus.waitingforaction || OperationStatus.draft:
), color = Colors.orange;
child: Text( break;
text, case OperationStatus.ok:
style: TextStyle( color = Colors.green;
color: color, break;
fontSize: 10, case OperationStatus.waitingfordeployment ||
fontWeight: FontWeight.bold, OperationStatus.waitingforsupport:
), color = Colors.blue;
), break;
}
return Chip(
label: Text("BOZZA", style: TextStyle(fontSize: 10, color: Colors.white)),
backgroundColor: color,
visualDensity: VisualDensity.compact,
); );
} }
void startNewOperation(BuildContext context) {
context.pushNamed('operation-form');
}
} }

View File

@@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/customers/blocs/customers_cubit.dart';
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
class CustomerSection extends StatelessWidget {
final OperationModel? currentOp;
const CustomerSection({super.key, required this.currentOp});
@override
Widget build(BuildContext context) {
final hasCustomer =
currentOp?.customerId != null && currentOp!.customerId!.isNotEmpty;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
'Cliente',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
InkWell(
onTap: () => _showCustomerModal(context), // Passiamo il context!
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.primary),
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.2),
),
child: Row(
children: [
const Icon(Icons.person),
const SizedBox(width: 12),
Expanded(
child: Text(
hasCustomer
? currentOp!.customerDisplayName!
: 'Seleziona Cliente *',
style: TextStyle(
fontWeight: hasCustomer
? FontWeight.bold
: FontWeight.normal,
color: hasCustomer ? null : Colors.grey,
),
),
),
const Icon(Icons.search),
],
),
),
),
],
);
}
// --- MODALE SELEZIONE CLIENTE ---
void _showCustomerModal(BuildContext context) {
String currentSearchQuery = '';
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.8,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Cliente',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
// Barra di Ricerca
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: 'Cerca per nome, telefono o email...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) {
currentSearchQuery = query;
context.read<CustomersCubit>().searchCustomers(query);
},
),
),
// Pulsante Nuovo Cliente
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.person_add),
label: const Text('Crea Nuovo Cliente'),
onPressed: () async {
final OperationsCubit operationsCubit = context
.read<OperationsCubit>();
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
final newCustomer = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<CustomersCubit>(),
child: QuickCustomerDialog(
initialQuery:
currentSearchQuery, // <-- Passiamo quello che ha digitato!
),
);
},
);
// Se l'ha creato davvero (e non ha premuto annulla)...
if (newCustomer != null) {
// 1. Aggiorniamo il form delle operazioni
operationsCubit.updateOperationFields(
customerId: newCustomer.id,
customerDisplayName: newCustomer.name,
);
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
if (context.mounted) {
Navigator.pop(modalContext);
}
}
},
),
),
const Divider(),
// Lista Clienti dal Bloc
Expanded(
child: BlocBuilder<CustomersCubit, CustomersState>(
builder: (context, state) {
if (state.status == CustomersStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.customers.isEmpty) {
return const Center(
child: Text(
'Nessun cliente trovato.',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
controller: scrollController,
itemCount: state.customers.length,
itemBuilder: (context, index) {
final customer = state.customers[index];
return ListTile(
leading: CircleAvatar(
child: Text(
customer.name.substring(0, 1).toUpperCase(),
),
),
title: Text(
customer.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
'${customer.phoneNumber}${customer.email}',
),
onTap: () {
// Aggiorniamo il form tramite il Cubit delle operazioni
context
.read<OperationsCubit>()
.updateOperationFields(
customerId: customer.id, // customer.id
customerDisplayName:
customer.name, // customer.name
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,423 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
import 'package:flux/features/master_data/products/ui/quick_product_dialog.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
class DetailsSection extends StatelessWidget {
final OperationModel? currentOp;
final String currentType;
final TextEditingController freeTextSubtypeController;
final TextEditingController freeTextDescriptionController;
final Widget durationQuickPicks;
const DetailsSection({
super.key,
required this.currentOp,
required this.currentType,
required this.freeTextSubtypeController,
required this.freeTextDescriptionController,
required this.durationQuickPicks,
});
bool _doesProviderMatchOperationType(dynamic provider, String operationType) {
if (operationType == 'Custom') return true;
switch (operationType) {
case 'AL':
case 'MNP':
return provider.mobile == true;
case 'NIP':
return provider.landline == true;
case 'UNICA':
return provider.landline == true || provider.mobile == true;
case 'Energy':
return provider.energy == true;
case 'Fin':
return provider.financing == true;
case 'Entertainment':
return provider.entertainment == true;
case 'TELEPASS':
return provider.telepass == true;
default:
return true;
}
}
void _showProviderModal(BuildContext context, String operationType) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.5,
minChildSize: 0.4,
maxChildSize: 0.8,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Gestore',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
const Divider(),
Expanded(
child: BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final allProviders = state.activeProviders;
final filteredProviders = allProviders
.where(
(p) => _doesProviderMatchOperationType(
p,
operationType,
),
)
.toList();
if (filteredProviders.isEmpty) {
return const Center(
child: Text(
'Nessun gestore compatibile con questo servizio.',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
controller: scrollController,
itemCount: filteredProviders.length,
itemBuilder: (context, index) {
final provider = filteredProviders[index];
return ListTile(
leading: const Icon(Icons.business),
title: Text(
provider.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onTap: () {
context
.read<OperationsCubit>()
.updateOperationFields(
providerId: provider.id,
providerDisplayName: provider.name,
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
void _showModelModal(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Modello',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
decoration: InputDecoration(
hintText: 'Cerca modello (es. iPhone 15...)',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) =>
context.read<ProductsCubit>().searchModels(query),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.add),
label: const Text('Aggiungi Modello al Volo'),
onPressed: () async {
final operationsCubit = context.read<OperationsCubit>();
final existingBrands = context
.read<ProductsCubit>()
.state
.brands;
final newModel = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<ProductsCubit>(),
child: QuickProductDialog(
existingBrands: existingBrands,
),
);
},
);
if (newModel != null) {
operationsCubit.updateOperationFields(
modelId: newModel.id,
modelDisplayName: newModel.nameWithBrand,
);
if (context.mounted) Navigator.pop(modalContext);
}
},
),
),
const Divider(),
Expanded(
child: BlocBuilder<ProductsCubit, ProductState>(
builder: (context, state) {
return ListView.builder(
controller: scrollController,
itemCount: state.models.length,
itemBuilder: (context, index) {
final deviceModel = state.models[index];
return ListTile(
leading: const Icon(Icons.devices),
title: Text(
deviceModel.nameWithBrand,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onTap: () {
context
.read<OperationsCubit>()
.updateOperationFields(
modelId: deviceModel.id,
modelDisplayName: deviceModel.nameWithBrand,
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// PROVIDER (Mostrato quasi sempre)
ListTile(
title: const Text('Seleziona Gestore'),
subtitle: Text(
(currentOp?.providerDisplayName != null &&
currentOp!.providerDisplayName!.isNotEmpty)
? currentOp!.providerDisplayName!
: 'Nessun gestore selezionato',
style: TextStyle(
color:
(currentOp?.providerId == null ||
currentOp!.providerId!.isEmpty)
? Colors.grey
: null,
fontWeight:
(currentOp?.providerId == null ||
currentOp!.providerId!.isEmpty)
? FontWeight.normal
: FontWeight.bold,
),
),
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () => _showProviderModal(context, currentType),
),
const SizedBox(height: 16),
// 1. SCENARIO ENERGY (Dropdown Fisso)
if (currentType == 'Energy') ...[
DropdownButtonFormField<String>(
initialValue:
(currentOp?.subtype != null && currentOp!.subtype!.isNotEmpty)
? currentOp!.subtype
: null,
decoration: const InputDecoration(labelText: 'Dettaglio Fornitura'),
items: [
'Luce',
'Gas',
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (val) {
if (val != null) {
context.read<OperationsCubit>().updateOperationFields(
subtype: val,
);
}
},
),
const SizedBox(height: 16),
TextFormField(
controller: freeTextDescriptionController,
decoration: InputDecoration(
labelText: currentType == 'Energy'
? 'Offerta scelta'
: 'Nome del servizio/offerta',
),
),
const SizedBox(height: 16),
],
// 2. SCENARIO FIN (Ricerca Modello/Prodotto)
if (currentType == 'Fin') ...[
ListTile(
title: const Text('Seleziona Dispositivo/Prodotto'),
subtitle: Text(
(currentOp?.modelDisplayName != null &&
currentOp!.modelDisplayName!.isNotEmpty)
? currentOp!.modelDisplayName!
: 'Nessun modello selezionato',
style: TextStyle(
color:
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
? Colors.grey
: null,
fontWeight:
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
? FontWeight.normal
: FontWeight.bold,
),
),
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () => _showModelModal(context),
),
const SizedBox(height: 16),
],
// 3. SCENARIO ENTERTAINMENT O CUSTOM (Testo libero)
if (currentType == 'Entertainment' || currentType == 'Custom') ...[
TextFormField(
controller: freeTextSubtypeController,
decoration: InputDecoration(
labelText: currentType == 'Entertainment'
? 'Piattaforma (es. Netflix, DAZN, Spotify...)'
: 'Specifica il servizio (es. Monopattino)',
),
),
const SizedBox(height: 16),
],
// SCADENZA (Reattivo per tipi complessi)
if ([
'Energy',
'Fin',
'Entertainment',
'Custom',
].contains(currentType)) ...[
const SizedBox(height: 8),
durationQuickPicks, // Passiamo i chips dall'esterno
const SizedBox(height: 16),
ListTile(
title: const Text('Data di Scadenza Effettiva'),
subtitle: Text(
currentOp?.expirationDate != null
? "${currentOp!.expirationDate!.day}/${currentOp!.expirationDate!.month}/${currentOp!.expirationDate!.year}"
: 'Nessuna scadenza impostata',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
trailing: const Icon(Icons.calendar_month, color: Colors.blue),
tileColor: Colors.blue.withValues(alpha: 0.05),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Colors.blue, width: 0.5),
),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate:
currentOp?.expirationDate ??
DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 3650)),
);
if (date != null && context.mounted) {
context.read<OperationsCubit>().updateOperationFields(
expirationDate: date,
);
}
},
),
const SizedBox(height: 16),
],
],
);
}
}

View File

@@ -0,0 +1,761 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flux/features/attachments/data/attachments_repository.dart';
import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart';
import 'package:flux/features/attachments/ui/quick_rename_dialog.dart';
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as p; // Se ti serve formattazione core
import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx
class _ExportItem {
final Uint8List bytes;
final String sourceName;
final bool isMultiPage;
final int pageIndex;
_ExportItem({
required this.bytes,
required this.sourceName,
required this.isMultiPage,
required this.pageIndex,
});
}
class OperationFilesSection extends StatefulWidget {
final OperationModel currentOp;
const OperationFilesSection({super.key, required this.currentOp});
@override
State<OperationFilesSection> createState() => _OperationFilesSectionState();
}
class _OperationFilesSectionState extends State<OperationFilesSection> {
String? _exportDirectory;
@override
void initState() {
super.initState();
_loadExportDirectory();
}
// --- GESTIONE CARTELLA CITRIX (SOLO DESKTOP) ---
Future<void> _loadExportDirectory() async {
if (kIsWeb) return;
final prefs = await SharedPreferences.getInstance();
setState(() {
_exportDirectory = prefs.getString('citrix_export_path');
});
}
Future<void> _selectExportDirectory() async {
final String? selectedDirectory = await FilePicker.getDirectoryPath(
dialogTitle: 'Seleziona la cartella di esportazione per TIM/Citrix',
);
if (selectedDirectory != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('citrix_export_path', selectedDirectory);
setState(() {
_exportDirectory = selectedDirectory;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cartella Export impostata: $selectedDirectory'),
),
);
}
}
}
// --- SELEZIONE FILE DAL PC/TELEFONO ---
Future<void> _pickFiles() async {
final result = await FilePicker.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'],
withData: true,
);
if (result != null && mounted) {
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
context.read<OperationFilesBloc>().add(
AddOperationFilesEvent(result.files),
);
}
}
// --- APERTURA VIEWER ---
void _openFile(AttachmentModel file) {
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
final operationFilesBloc = context.read<OperationFilesBloc>();
Navigator.push(
context,
MaterialPageRoute(
builder: (viewerContext) => BlocProvider.value(
value: operationFilesBloc,
child: AttachmentViewerScreen(
attachment: file,
onRename: (newName) {
// Spara l'evento al BLoC e lui farà il resto!
operationFilesBloc.add(RenameOperationFileEvent(file, newName));
},
onDelete: () {
operationFilesBloc.add(DeleteSpecificOperationFileEvent(file));
},
),
),
),
);
}
Future<void> _exportMergedPdf(List<AttachmentModel> selectedFiles) async {
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
);
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
// 1. "FLATTEN" DI TUTTO (Stessa magia di prima)
List<Uint8List> allPagesAsImages = [];
final repository = GetIt.I.get<AttachmentsRepository>();
for (var file in selectedFiles) {
Uint8List? fileBytes;
if (file.localBytes != null) {
fileBytes = file.localBytes;
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
fileBytes = await repository.downloadAttachmentBytes(
file.storagePath!,
);
}
if (fileBytes == null) continue;
if (file.extension == 'pdf') {
final document = await px.PdfDocument.openData(fileBytes);
for (int i = 1; i <= document.pagesCount; i++) {
final page = await document.getPage(i);
final pageImage = await page.render(
width: page.width * 2,
height: page.height * 2,
format: px.PdfPageImageFormat.jpeg,
);
if (pageImage != null) {
allPagesAsImages.add(pageImage.bytes);
}
await page.close();
}
await document.close();
} else {
// È un'immagine
allPagesAsImages.add(fileBytes);
}
}
if (mounted) Navigator.pop(context); // Togliamo il loading
// Se per qualche motivo la lista è vuota, usciamo
if (allPagesAsImages.isEmpty) return;
// 2. LOGICA DEL NOME SUGGERITO
String suggestedName;
if (selectedFiles.length == 1) {
// Se c'è un solo file (es. ho selezionato 3 foto ma poi ho deselezionato le altre)
suggestedName = selectedFiles.first.name;
} else {
// Se sono più file uniti
suggestedName = '${widget.currentOp.customerDisplayName}_Unito';
}
if (!mounted) return;
// 3. DIALOG DI CONFERMA (Mostriamo la PRIMA pagina come anteprima per fargli capire cos'è)
final finalName = await showDialog<String>(
context: context,
builder: (_) => QuickRenameDialog(
suggestedName: suggestedName,
previewWidget: Image.memory(
allPagesAsImages.first,
fit: BoxFit.contain,
),
),
);
if (finalName == null || finalName.isEmpty) return; // Ha annullato
// 4. CREAZIONE DEL PDF UNICO (IL MERGE VERO E PROPRIO)
final pdf = pw.Document();
// Cicliamo su tutte le immagini estratte e creiamo una pagina per ognuna
for (var imageBytes in allPagesAsImages) {
final pdfImage = pw.MemoryImage(imageBytes);
pdf.addPage(
pw.Page(
margin: pw.EdgeInsets.zero,
build: (pw.Context context) {
return pw.Center(child: pw.Image(pdfImage));
},
),
);
}
final mergedPdfBytes = await pdf.save();
// 5. SALVATAGGIO SUL DISCO
if (kIsWeb) {
// Trigger download web
} else {
final fileToSave = File('$_exportDirectory/$finalName.pdf');
await fileToSave.writeAsBytes(mergedPdfBytes);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('PDF Multi-pagina creato e salvato con successo!'),
),
);
}
} catch (e) {
if (mounted) {
// Se il loading è ancora aperto, lo chiudiamo
if (Navigator.canPop(context)) Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Errore durante l\'unione: $e')));
}
}
}
Future<void> _exportSplitPdfs(List<AttachmentModel> selectedFiles) async {
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
);
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
// 1. "FLATTEN" INTELLIGENTE (Ora usiamo la nostra classe _ExportItem)
List<_ExportItem> itemsToExport = [];
final repository = GetIt.I.get<AttachmentsRepository>();
for (var file in selectedFiles) {
Uint8List? fileBytes;
if (file.localBytes != null) {
fileBytes = file.localBytes;
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
fileBytes = await repository.downloadAttachmentBytes(
file.storagePath!,
);
}
if (fileBytes == null) continue;
// Recuperiamo il nome che l'utente ha (magari) già impostato
final baseName = file.name ?? 'Documento';
if (file.extension == 'pdf') {
final document = await px.PdfDocument.openData(fileBytes);
final isMulti =
document.pagesCount > 1; // Controlliamo se è multipagina!
for (int i = 1; i <= document.pagesCount; i++) {
final page = await document.getPage(i);
final pageImage = await page.render(
width: page.width * 2,
height: page.height * 2,
format: px.PdfPageImageFormat.jpeg,
);
if (pageImage != null) {
// Salviamo l'immagine CON il suo contesto storico
itemsToExport.add(
_ExportItem(
bytes: pageImage.bytes,
sourceName: baseName,
isMultiPage: isMulti,
pageIndex: i,
),
);
}
await page.close();
}
await document.close();
} else {
// SE È UN'IMMAGINE, la salviamo come singola pagina
itemsToExport.add(
_ExportItem(
bytes: fileBytes,
sourceName: baseName,
isMultiPage: false,
pageIndex: 1,
),
);
}
}
if (mounted) Navigator.pop(context);
// 2. IL CICLO UX
for (var item in itemsToExport) {
if (!mounted) return;
// LA TUA MAGIA UX SUI NOMI:
// Se è singolo (foto o PDF da 1 pag) -> Usa il nome originale nudo e crudo!
// Se è multipagina -> Usa il nome originale + il numero di pagina
String suggestedName = item.sourceName;
if (item.isMultiPage) {
suggestedName = '${item.sourceName}_Pag_${item.pageIndex}';
}
final finalName = await showDialog<String>(
context: context,
builder: (_) => QuickRenameDialog(
suggestedName: suggestedName,
previewWidget: Image.memory(item.bytes, fit: BoxFit.contain),
),
);
if (finalName == null || finalName.isEmpty) continue;
// CREAZIONE DEL PDF SINGOLO
final pdf = pw.Document();
final pdfImage = pw.MemoryImage(item.bytes); // Usiamo item.bytes!
pdf.addPage(
pw.Page(
margin: pw.EdgeInsets.zero,
build: (pw.Context context) {
return pw.Center(child: pw.Image(pdfImage));
},
),
);
final singlePdfBytes = await pdf.save();
if (kIsWeb) {
// Trigger download web
} else {
final fileToSave = File('$_exportDirectory/$finalName.pdf');
await fileToSave.writeAsBytes(singlePdfBytes);
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Esportazione completata con successo!'),
),
);
}
} catch (e) {
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Errore: $e')));
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// USIAMO IL TUO BLOC!
return BlocBuilder<OperationFilesBloc, OperationFilesState>(
builder: (context, state) {
final allFiles = state.allFiles;
final selectedFiles = state.selectedFiles;
final hasSelection = selectedFiles.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. SETTINGS CARTELLA (Solo visibile su Desktop)
if (!kIsWeb)
Card(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
elevation: 0,
margin: const EdgeInsets.only(bottom: 16),
child: ListTile(
leading: Icon(
Icons.folder_special,
color: theme.colorScheme.primary,
),
title: const Text(
'Cartella Export (Es. Citrix TIM)',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
_exportDirectory ??
'Nessuna cartella selezionata. Clicca per impostare.',
style: TextStyle(
color: _exportDirectory == null
? theme.colorScheme.error
: null,
),
),
trailing: const Icon(Icons.settings),
onTap: _selectExportDirectory,
),
),
// 2. ACTION BAR DINAMICA
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
// Bottone di Aggiunta
ElevatedButton.icon(
icon: const Icon(Icons.add_photo_alternate),
label: const Text('Aggiungi File'),
onPressed: state.status == OperationFilesStatus.uploading
? null
: _pickFiles,
),
const SizedBox(width: 12),
// NUOVO: SELEZIONA / DESELEZIONA TUTTO
if (allFiles.isNotEmpty) ...[
TextButton.icon(
icon: Icon(
selectedFiles.length == allFiles.length
? Icons.deselect
: Icons.select_all,
),
label: Text(
selectedFiles.length == allFiles.length
? 'Deseleziona Tutto'
: 'Seleziona Tutto',
),
onPressed: () {
if (selectedFiles.length == allFiles.length) {
context.read<OperationFilesBloc>().add(
ClearOperationFileSelectionEvent(),
);
} else {
context.read<OperationFilesBloc>().add(
SelectAllOperationFilesEvent(),
);
}
},
),
],
const SizedBox(width: 12),
// Loader di upload
if (state.status == OperationFilesStatus.uploading)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
const Spacer(),
// Azioni visibili SOLO se c'è una selezione!
if (hasSelection) ...[
// Bottone Elimina
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Elimina selezionati',
onPressed: () {
context.read<OperationFilesBloc>().add(
DeleteOperationFilesEvent(),
);
},
),
// Bottone Associa a Cliente
if (widget.currentOp.customerId != null &&
widget.currentOp.customerId!.isNotEmpty)
IconButton(
icon: const Icon(Icons.person_add, color: Colors.blue),
tooltip: 'Copia nei documenti del Cliente',
onPressed: () {
context.read<OperationFilesBloc>().add(
LinkFilesToCustomerEvent(
customerId: widget.currentOp.customerId!,
),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('File copiati nella scheda cliente!'),
),
);
},
),
// IL NUOVO BOTTONE ESPORTA CON MENU A TENDINA
PopupMenuButton<String>(
tooltip: 'Opzioni di esportazione',
position: PopupMenuPosition
.under, // Opzionale: fa aprire il menu sotto al bottone
onSelected: (value) {
if (value == 'merge') {
_exportMergedPdf(selectedFiles);
} else if (value == 'split') {
_exportSplitPdfs(selectedFiles);
}
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
const PopupMenuItem<String>(
value: 'merge',
child: ListTile(
leading: Icon(
Icons.merge_type,
color: Colors.blue,
),
title: Text('Unisci in un singolo PDF'),
),
),
const PopupMenuItem<String>(
value: 'split',
child: ListTile(
leading: Icon(
Icons.splitscreen,
color: Colors.orange,
),
title: Text(
'Dividi: un PDF per ogni pagina/foto',
),
),
),
],
// IL FIX È QUI SOTTO: AbsorbPointer + onPressed vuoto
child: AbsorbPointer(
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
),
icon: const Icon(Icons.picture_as_pdf),
label: Text('Esporta (${selectedFiles.length})'),
onPressed: () {}, // Manteniamo vivo il colore!
),
),
),
],
],
),
const SizedBox(height: 16),
// 3. GRIGLIA DEI FILE
if (allFiles.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
border: Border.all(
color: theme.dividerColor,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(8),
),
child: const Column(
children: [
Icon(Icons.upload_file, size: 48, color: Colors.grey),
SizedBox(height: 8),
Text(
'Nessun file allegato. Usa il pulsante per aggiungere documenti o foto.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
],
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.8,
),
itemCount: allFiles.length,
itemBuilder: (context, index) {
final file = allFiles[index];
final isPdf = file.extension == 'pdf';
final isSelected = selectedFiles.contains(file);
final isLocal =
file.localBytes !=
null; // Per capire se è un file in bozza
return Stack(
children: [
// CARD DEL FILE
InkWell(
onTap: () => _openFile(file),
onLongPress: () {
// Selezione rapida con long press!
context.read<OperationFilesBloc>().add(
ToggleOperationFileSelectionEvent(file),
);
},
borderRadius: BorderRadius.circular(8),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.dividerColor,
width: isSelected ? 3 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Anteprima
Expanded(
child: Container(
decoration: BoxDecoration(
color: theme
.colorScheme
.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(8),
),
),
child: isPdf
? const Icon(
Icons.picture_as_pdf,
size: 48,
color: Colors.red,
)
: isLocal
? ClipRRect(
borderRadius:
const BorderRadius.vertical(
top: Radius.circular(8),
),
child: Image.memory(
file.localBytes!,
fit: BoxFit.cover,
),
)
: const Icon(
Icons.image,
size: 48,
color: Colors.blue,
), // Da remoto metterai il tuo NetworkImage se vuoi
),
),
// Nome File
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
file.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.center,
),
),
],
),
),
),
// CHECKBOX DI SELEZIONE
Positioned(
top: 4,
right: 4,
child: InkWell(
onTap: () {
context.read<OperationFilesBloc>().add(
ToggleOperationFileSelectionEvent(file),
);
},
child: Container(
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: Colors.white.withValues(alpha: 0.8),
shape: BoxShape.circle,
border: Border.all(
color: theme.colorScheme.primary,
),
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Icon(
isSelected ? Icons.check : Icons.circle,
size: 16,
color: isSelected
? Colors.white
: Colors.transparent,
),
),
),
),
),
// BADGE "IN ATTESA" (Se è locale ma la pratica è salvata)
if (isLocal)
Positioned(
top: 4,
left: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Bozza',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
},
),
],
);
},
);
}
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:get_it/get_it.dart';
// IMPORTA IL TUO CUBIT DELLO STAFF
// import 'package:flux/features/staff/blocs/staff_cubit.dart';
class StaffSection extends StatelessWidget {
final OperationModel? currentOp;
const StaffSection({super.key, required this.currentOp});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final selectedStaffId =
currentOp?.staffId ??
GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
'Operatore',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) {
// Dati finti per farti vedere la UI, piallali quando attacchi il BlocBuilder!
final staffMembers = state.storeStaff;
final currentLoggedStaffMember = GetIt.I
.get<SessionCubit>()
.state
.currentStaffMember;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: staffMembers.map((staff) {
final isSelected = staff.id == selectedStaffId;
return GestureDetector(
onTap: () {
// Aggiorniamo la form con un solo tap!
context.read<OperationsCubit>().updateOperationFields(
staffId: staff.id,
staffDisplayName: staff.name,
);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(right: 12.0),
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 10.0,
),
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.dividerColor,
width: 1.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: theme.colorScheme.primary.withValues(
alpha: 0.3,
),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 12,
backgroundColor: isSelected
? Colors.white
: theme.colorScheme.primaryContainer,
child: Text(
staff.name.substring(0, 1).toUpperCase(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 8),
Text(
staff == currentLoggedStaffMember
? 'Tu (${staff.name})'
: staff.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.w500,
color: isSelected
? Colors.white
: theme.colorScheme.onSurface,
),
),
],
),
),
);
}).toList(),
),
);
},
),
],
);
}
}

View File

@@ -1,232 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/services/data/services_repository.dart';
import 'package:flux/features/services/models/service_file_model.dart';
import 'package:flux/features/services/models/service_model.dart';
import 'package:get_it/get_it.dart';
part 'service_files_events.dart';
part 'service_files_state.dart';
class ServiceFilesBloc extends Bloc<ServiceFilesEvent, ServiceFilesState> {
final _repository = GetIt.I.get<ServicesRepository>();
final String? serviceId;
ServiceFilesBloc({this.serviceId})
: super(
ServiceFilesState(
status: ServiceFilesStatus.initial,
serviceId: serviceId,
),
) {
on<ServiceSavedEvent>(_onServiceSaved);
on<LoadServiceFilesEvent>(_onLoadServiceFiles);
on<AddServiceFilesEvent>(_onAddServiceFiles);
on<UploadServiceFilesEvent>(_onUploadServiceFiles);
on<UploadMultipleServiceFilesEvent>(_onUploadMultipleServiceFiles);
on<DeleteServiceFilesEvent>(_onDeleteServiceFiles);
on<ToggleServiceFileSelectionEvent>(_onToggleServiceFileSelection);
// Se il BLoC nasce con un ID, accendiamo subito lo stream!
if (serviceId != null) {
add(LoadServiceFilesEvent(serviceId: serviceId));
}
}
FutureOr<void> _onServiceSaved(
ServiceSavedEvent event,
Emitter<ServiceFilesState> emit,
) {
// 1. Aggiorniamo l'ID nello stato
// 2. PIALLIAMO i file locali: ormai sono partiti per Supabase!
// Così la UI si pulisce all'istante e aspetta quelli remoti.
emit(
state.copyWith(
serviceId: event.serviceId,
localFiles: [], // <-- LA MAGIA ANTI-DUPLICATI
),
);
// Lanciamo il caricamento
add(LoadServiceFilesEvent(serviceId: event.serviceId));
}
FutureOr<void> _onLoadServiceFiles(
LoadServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
// Usiamo l'ID dell'evento, e se non c'è usiamo quello dello stato
final currentId = event.serviceId ?? state.serviceId;
if (currentId != null) {
emit(state.copyWith(status: ServiceFilesStatus.loading));
await emit.forEach(
_repository.getServiceFilesStream(
currentId,
), // <-- Usiamo l'ID corretto!
onData: (data) => state.copyWith(
status: ServiceFilesStatus.success,
remoteFiles: data,
),
onError: (error, stackTrace) => state.copyWith(
status: ServiceFilesStatus.failure,
error: error.toString(),
),
);
}
}
void _onAddServiceFiles(
AddServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
final currentId = state.serviceId;
// BIVIO 1: PRATICA NUOVA (Nessun ID)
if (currentId == null) {
// Mettiamo i file nel "parcheggio" locale dello State
final newLocalFiles = event.files.map((file) {
return ServiceFileModel(
id: null,
serviceId: serviceId ?? '',
name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(),
storagePath: '',
fileSize: file.size,
localBytes: file.bytes,
);
}).toList();
final List<ServiceFileModel> updatedLocalFiles = [
...state.localFiles,
...newLocalFiles,
];
emit(
state.copyWith(
localFiles: updatedLocalFiles,
status: ServiceFilesStatus.success,
),
);
return;
}
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID)
emit(state.copyWith(status: ServiceFilesStatus.uploading));
try {
// Logica identica a quella che abbiamo fatto per i clienti
for (var file in event.files) {
await _repository.uploadAndRegisterServiceFile(
serviceId: serviceId!,
pickedFile: file,
);
}
emit(state.copyWith(status: ServiceFilesStatus.success));
} catch (e) {
emit(
state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onUploadServiceFiles(
UploadServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
if (event.pickedFiles == null && event.photos == null) return;
if (event.pickedFiles!.isEmpty && event.photos!.isEmpty) return;
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID
emit(state.copyWith(status: ServiceFilesStatus.uploading));
try {
// Logica identica a quella che abbiamo fatto per i clienti
if (event.pickedFiles != null && event.pickedFiles!.isNotEmpty) {
for (var file in event.pickedFiles!) {
await _repository.uploadAndRegisterServiceFile(
serviceId: state.serviceId!,
pickedFile: file,
);
}
}
emit(state.copyWith(status: ServiceFilesStatus.success));
} catch (e) {
emit(
state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onUploadMultipleServiceFiles(
UploadMultipleServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
if (event.files.isEmpty) {
emit(
state.copyWith(
status: ServiceFilesStatus.failure,
error: "Nessun file selezionato",
),
);
return;
}
emit(state.copyWith(status: ServiceFilesStatus.uploading, error: null));
try {
// 2. Creiamo una lista di "Promesse" (Futures) per il repository
final List<Future<void>> uploadTasks = [];
for (var file in event.files) {
// Aggiungiamo il task alla lista, ma NON usiamo await qui dentro!
uploadTasks.add(
_repository.uploadAndRegisterServiceFile(
serviceId: state.serviceId!,
pickedFile: file,
),
);
}
// 3. ESECUZIONE PARALLELA!
// Aspettiamo che tutti i file siano caricati contemporaneamente.
await Future.wait(uploadTasks);
// 4. GRAN FINALE: Tutto caricato, emettiamo il success!
emit(state.copyWith(status: ServiceFilesStatus.success));
} catch (e) {
// Se anche un solo file fallisce, catturiamo l'errore
emit(
state.copyWith(
status: ServiceFilesStatus.failure,
error: "Errore durante l'upload multiplo: $e",
),
);
}
}
FutureOr<void> _onDeleteServiceFiles(
DeleteServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
emit(state.copyWith(status: ServiceFilesStatus.loading));
try {
await _repository.deleteServiceFiles(state.selectedFiles);
emit(
state.copyWith(status: ServiceFilesStatus.success, selectedFiles: []),
);
} catch (e) {
emit(
state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onToggleServiceFileSelection(
ToggleServiceFileSelectionEvent event,
Emitter<ServiceFilesState> emit,
) {
List<ServiceFileModel> selectedFiles = List.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file);
} else {
selectedFiles.add(event.file);
}
emit(state.copyWith(selectedFiles: selectedFiles));
}
}

View File

@@ -1,56 +0,0 @@
part of 'service_files_bloc.dart';
abstract class ServiceFilesEvent extends Equatable {
const ServiceFilesEvent();
@override
List<Object?> get props => [];
}
class ServiceSavedEvent extends ServiceFilesEvent {
final String serviceId;
const ServiceSavedEvent(this.serviceId);
@override
List<Object?> get props => [serviceId];
}
class LoadServiceFilesEvent extends ServiceFilesEvent {
final String? serviceId;
final ServiceModel? service;
const LoadServiceFilesEvent({this.serviceId, this.service});
@override
List<Object?> get props => [serviceId, service];
}
class AddServiceFilesEvent extends ServiceFilesEvent {
final List<PlatformFile> files;
const AddServiceFilesEvent(this.files);
@override
List<Object?> get props => [files];
}
class UploadServiceFilesEvent extends ServiceFilesEvent {
final List<PlatformFile>? pickedFiles;
final List<File>? photos;
const UploadServiceFilesEvent({this.pickedFiles, this.photos});
@override
List<Object?> get props => [pickedFiles, photos];
}
class UploadMultipleServiceFilesEvent extends ServiceFilesEvent {
final List<PlatformFile> files;
const UploadMultipleServiceFilesEvent(this.files);
@override
List<Object?> get props => [files];
}
class DeleteServiceFilesEvent extends ServiceFilesEvent {}
class ToggleServiceFileSelectionEvent extends ServiceFilesEvent {
final ServiceFileModel file;
const ToggleServiceFileSelectionEvent(this.file);
}

View File

@@ -1,52 +0,0 @@
part of 'service_files_bloc.dart';
enum ServiceFilesStatus { initial, loading, uploading, success, failure }
class ServiceFilesState extends Equatable {
const ServiceFilesState({
this.serviceId,
required this.status,
this.error,
this.localFiles = const [],
this.remoteFiles = const [],
this.selectedFiles = const [],
});
final String? serviceId;
final ServiceFilesStatus status;
final String? error;
final List<ServiceFileModel> localFiles;
final List<ServiceFileModel> remoteFiles;
final List<ServiceFileModel> selectedFiles;
@override
List<Object?> get props => [
serviceId,
status,
error,
localFiles,
remoteFiles,
selectedFiles,
];
List<ServiceFileModel> get allFiles => [...remoteFiles, ...localFiles];
ServiceFilesState copyWith({
String? serviceId,
ServiceFilesStatus? status,
String? error,
List<ServiceFileModel>? localFiles,
List<ServiceFileModel>? remoteFiles,
List<ServiceFileModel>? selectedFiles,
}) {
return ServiceFilesState(
serviceId: serviceId ?? this.serviceId,
status: status ?? this.status,
error: error,
localFiles: localFiles ?? this.localFiles,
remoteFiles: remoteFiles ?? this.remoteFiles,
selectedFiles: selectedFiles ?? this.selectedFiles,
);
}
}

Some files were not shown because too many files have changed in this diff Show More