diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..1fb6a4c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,31 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "flux", + "request": "launch", + "type": "dart" + }, + { + "name": "s25", + "request":"launch", + "type":"dart", + "deviceId": "RFCY51YEK1N" + }, + { + "name":"mac", + "request":"launch", + "type":"dart", + "deviceId": "macos" + } + ], + "compounds": [ + { + "name": "Compound", + "configurations": ["s25","mac"] + } + ] +} \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..cd99714 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,7 @@ +## General Instructions + +- Prefer the Flutter clean architecture +- Prefer Cubit over Bloc and Stateful where is good practice +- Data Models must always extend Equatable, have copyWith, empty and fromMap, toMap when they'll be saved in db +- Use GoRouter +- Use Enums when possible instead of hardcoded Strings or Numbers for better readability and less error prone diff --git a/lib/core/blocs/session/session_bloc.dart b/lib/core/blocs/session/session_bloc.dart deleted file mode 100644 index 1583fce..0000000 --- a/lib/core/blocs/session/session_bloc.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flux/core/enums/enums.dart'; -import 'package:flux/features/company/models/company_model.dart'; -import 'package:flux/features/master_data/store/data/store_repository.dart'; -import 'package:flux/features/master_data/store/models/store_model.dart'; -import 'package:get_it/get_it.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'dart:async'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -part 'session_events.dart'; -part 'session_state.dart'; - -class SessionBloc extends Bloc { - final SupabaseClient _supabase = GetIt.I.get(); - final StoreRepository _storeRepository = GetIt.I.get(); - StreamSubscription? _authSubscription; - - SessionBloc() : super(const SessionState(status: SessionStatus.unknown)) { - on((event, emit) { - // 1. Controlla la sessione attuale al boot - final session = _supabase.auth.currentSession; - if (session != null) { - add(UserChanged(session.user.id)); - } else { - add(UserChanged(null)); - } - - // 2. Ascolta i cambiamenti futuri (login, logout, token scaduto) - _authSubscription = _supabase.auth.onAuthStateChange.listen((data) { - final userId = data.session?.user.id; - add(UserChanged(userId)); - }); - }); - - on((event, emit) async { - if (event.userId == null) { - emit(SessionState(status: SessionStatus.unauthenticated)); - return; - } - // 1. Controlla se l'utente ha una Company - final companyJson = await _supabase - .from('company') - .select() - .eq('user_id', event.userId!) - .maybeSingle(); - - if (companyJson == null) { - emit( - SessionState( - status: SessionStatus.authenticatedNoCompany, - userId: event.userId, - ), - ); - return; - } - CompanyModel company = CompanyModel.fromJson(companyJson); - - // 2. Controlla i negozi - final stores = await _storeRepository.fetchAllCompanyStores(company.id); - - if (stores.isEmpty) { - emit( - SessionState( - status: SessionStatus.authenticatedNoStore, - userId: event.userId, - company: company, - ), - ); - return; - } - - // 3. Tutto ok, gestiamo le SharedPreferences per il negozio - final prefs = GetIt.I.get(); - String? lastStoreId = prefs.getString(PrefKeys.lastStore.value); - - // Se non c'è nelle SharedPreferences, prendi il primo della lista - if (lastStoreId == null || !stores.any((s) => s.id == lastStoreId)) { - lastStoreId = stores.first.id; - await prefs.setString('last_store_id', lastStoreId!); - } - final selectedStore = stores.firstWhere((s) => s.id == lastStoreId); - emit( - SessionState( - status: SessionStatus.ready, - userId: event.userId, - company: company, - selectedStore: selectedStore, - availableStores: stores, - ), - ); - }); - } - - @override - Future close() { - _authSubscription?.cancel(); - return super.close(); - } -} diff --git a/lib/core/blocs/session/session_cubit.dart b/lib/core/blocs/session/session_cubit.dart new file mode 100644 index 0000000..e3f0f70 --- /dev/null +++ b/lib/core/blocs/session/session_cubit.dart @@ -0,0 +1,133 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/data/core_repository.dart'; +import 'package:flux/features/company/models/company_model.dart'; +import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; +import 'package:flux/features/master_data/store/models/store_model.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:collection/collection.dart'; // Per firstWhereOrNull + +// Importa lo state con l'Enum e il CoreRepository... +part 'session_state.dart'; + +class SessionCubit extends Cubit { + final CoreRepository _repository; + final SharedPreferences _prefs; // Iniettato via GetIt + final SupabaseClient _supabase = Supabase.instance.client; + + static const String _lastStoreKey = 'last_selected_store_id'; + + SessionCubit(this._repository, this._prefs) + : super(const SessionState(status: SessionStatus.initial)) { + initializeSession(); + // Possiamo metterci in ascolto dei cambiamenti di Auth (Login/Logout) + _supabase.auth.onAuthStateChange.listen((data) { + final AuthChangeEvent event = data.event; + if (event == AuthChangeEvent.signedIn) { + initializeSession(); + } else if (event == AuthChangeEvent.signedOut) { + emit(const SessionState(status: SessionStatus.unauthenticated)); + } + }); + } + + Future initializeSession() async { + final user = _supabase.auth.currentUser; + if (user == null) { + return emit(state.copyWith(status: SessionStatus.unauthenticated)); + } + + try { + // 1. Controllo Azienda + final company = await _repository.getCompanyByOwnerId(user.id); + if (company == null) { + return emit( + state.copyWith( + status: SessionStatus.onboardingRequired, + user: user, + onboardingStep: OnboardingStep.company, + ), + ); + } else { + emit(state.copyWith(company: company)); + } + + // 2. Controllo Negozi + final stores = await _repository.getStoresByCompanyId(company.id!); + if (stores.isEmpty) { + return emit( + state.copyWith( + status: SessionStatus.onboardingRequired, + user: user, + company: company, + onboardingStep: OnboardingStep.store, + ), + ); + } else { + emit(state.copyWith(currentStore: stores.first)); + } + + // 3. Controllo Staff (Paziente Zero) + final staff = await _repository.getStaffMemberByUserId(user.id); + if (staff == null) { + return emit( + state.copyWith( + status: SessionStatus.onboardingRequired, + user: user, + company: company, + onboardingStep: OnboardingStep.staff, + ), + ); + } + + // --- TUTTO COMPLETATO: LOGICA DEL NEGOZIO DI DEFAULT --- + + // Leggiamo l'ultimo negozio dalle SharedPreferences + final lastStoreId = _prefs.getString(_lastStoreKey); + + // Cerchiamo quel negozio nella lista. Se non c'è (magari è stato eliminato), prendiamo il primo. + final activeStore = + stores.firstWhereOrNull((s) => s.id == lastStoreId) ?? stores.first; + + // Se non avevamo il lastStoreId salvato, salviamolo ora + if (lastStoreId != activeStore.id && activeStore.id != null) { + await _prefs.setString(_lastStoreKey, activeStore.id!); + } + + // 4. BENVENUTO A BORDO + emit( + state.copyWith( + status: SessionStatus.authenticated, + user: user, + company: company, + currentStore: activeStore, + currentStaff: staff, + onboardingStep: OnboardingStep.none, // Svuotiamo l'onboarding + ), + ); + } catch (e) { + // Se esplode il database, non lasciamo l'app freezata in 'initial' + emit( + state.copyWith( + status: SessionStatus + .unauthenticated, // O un nuovo stato SessionStatus.error + ), + ); + } + } + + // --- FUNZIONE EXTRA: CAMBIO NEGOZIO DALLA DASHBOARD --- + Future changeStore(StoreModel newStore) async { + if (newStore.id != null) { + await _prefs.setString(_lastStoreKey, newStore.id!); + emit(state.copyWith(currentStore: newStore)); + } + } + + // --- LOGOUT --- + Future signOut() async { + await _supabase.auth.signOut(); + // Non serve emettere stato qui, ci pensa il listener nel costruttore! + } +} diff --git a/lib/core/blocs/session/session_events.dart b/lib/core/blocs/session/session_events.dart deleted file mode 100644 index 8f6accb..0000000 --- a/lib/core/blocs/session/session_events.dart +++ /dev/null @@ -1,10 +0,0 @@ -part of 'session_bloc.dart'; - -abstract class SessionEvent {} - -class AppStarted extends SessionEvent {} - -class UserChanged extends SessionEvent { - final String? userId; - UserChanged(this.userId); -} diff --git a/lib/core/blocs/session/session_state.dart b/lib/core/blocs/session/session_state.dart index c85154c..fbcb65e 100644 --- a/lib/core/blocs/session/session_state.dart +++ b/lib/core/blocs/session/session_state.dart @@ -1,51 +1,69 @@ -part of 'session_bloc.dart'; +part of 'session_cubit.dart'; +/// Definisce lo stato macroscopico della sessione enum SessionStatus { - unknown, + initial, unauthenticated, - authenticatedNoCompany, // Loggato ma deve creare l'azienda - authenticatedNoStore, // Ha l'azienda ma deve creare/scegliere il primo negozio - ready, + onboardingRequired, + authenticated, +} + +/// Definisce lo step esatto dell'onboarding (Paranoia Mode) +enum OnboardingStep { + none, // Non serve onboarding + company, // Step 1: Manca l'azienda + store, // Step 2: Manca il negozio + staff, // Step 3: Manca il profilo staff ("Paziente Zero") + completed, // Flusso terminato con successo } class SessionState extends Equatable { final SessionStatus status; - final String? userId; + final User? user; // Utente di Supabase Auth final CompanyModel? company; - final StoreModel? selectedStore; - final List availableStores; // Utile per uno switcher in futuro + final StoreModel? currentStore; + final StaffMemberModel? currentStaff; + final OnboardingStep onboardingStep; const SessionState({ - this.status = SessionStatus.unknown, - this.userId, + this.status = SessionStatus.initial, + this.user, this.company, - this.selectedStore, - this.availableStores = const [], + this.currentStore, + this.currentStaff, + this.onboardingStep = OnboardingStep.none, }); + /// Metodo per creare una copia dello stato modificando solo i campi necessari + SessionState copyWith({ + SessionStatus? status, + User? user, + CompanyModel? company, + StoreModel? currentStore, + StaffMemberModel? currentStaff, + OnboardingStep? onboardingStep, + }) { + return SessionState( + status: status ?? this.status, + user: user ?? this.user, + company: company ?? this.company, + currentStore: currentStore ?? this.currentStore, + currentStaff: currentStaff ?? this.currentStaff, + onboardingStep: onboardingStep ?? this.onboardingStep, + ); + } + @override List get props => [ status, - userId, + user, company, - selectedStore, - availableStores, + currentStore, + currentStaff, + onboardingStep, ]; - // copyWith per aggiornare solo un pezzo (es. quando cambi negozio) - SessionState copyWith({ - SessionStatus? status, - String? userId, - CompanyModel? company, - StoreModel? selectedStore, - List? availableStores, - }) { - return SessionState( - status: status ?? this.status, - userId: userId ?? this.userId, - company: company ?? this.company, - selectedStore: selectedStore ?? this.selectedStore, - availableStores: availableStores ?? this.availableStores, - ); - } + // Helper rapidi per la UI + bool get isAuthenticated => status == SessionStatus.authenticated; + bool get needsOnboarding => status == SessionStatus.onboardingRequired; } diff --git a/lib/core/data/core_repository.dart b/lib/core/data/core_repository.dart new file mode 100644 index 0000000..eebec0f --- /dev/null +++ b/lib/core/data/core_repository.dart @@ -0,0 +1,111 @@ +import 'package:flutter/foundation.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/features/company/models/company_model.dart'; +import 'package:flux/features/master_data/store/models/store_model.dart'; +import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; +import 'package:get_it/get_it.dart'; +// Importa i tuoi modelli... +import 'package:supabase_flutter/supabase_flutter.dart'; + +class CoreRepository { + final _supabase = Supabase.instance.client; + + // --- QUERY DI SESSIONE (Uso di maybeSingle per evitare crash) --- + + Future getCompanyByOwnerId(String userId) async { + try { + final response = await _supabase + .from('company') + .select() + .eq('user_id', userId) // <-- Assicurati di avere questo campo nel DB! + .maybeSingle(); + + if (response == null) return null; + return CompanyModel.fromMap(response); + } catch (e) { + debugPrint('Errore recupero azienda: $e'); + throw Exception('Errore recupero azienda: $e'); + } + } + + Future> getStoresByCompanyId(String companyId) async { + try { + final response = await _supabase + .from('store') + .select() + .eq('company_id', companyId) + .eq('is_active', true) // Buona pratica + .order('nome'); // O come si chiama il campo nome + + return (response as List).map((s) => StoreModel.fromMap(s)).toList(); + } catch (e) { + debugPrint('Errore recupero negozi: $e'); + throw Exception('Errore recupero negozi: $e'); + } + } + + Future getStaffMemberByUserId(String userId) async { + try { + final response = await _supabase + .from('staff_member') + .select() + .eq('user_id', userId) + .maybeSingle(); + + if (response == null) return null; + return StaffMemberModel.fromMap(response); + } catch (e) { + debugPrint('Errore recupero profilo staff: $e'); + throw Exception('Errore recupero profilo staff: $e'); + } + } + + // --- MUTAZIONI PER L'ONBOARDING --- + + Future createCompany(CompanyModel company) async { + try { + final response = await _supabase + .from('company') + .insert(company.toMap()) + .select() + .single(); + return CompanyModel.fromMap(response); + } catch (e) { + debugPrint('Creazione azienda fallita: $e'); + throw Exception('Creazione azienda fallita: $e'); + } + } + + Future createStore(StoreModel store) async { + try { + final response = await _supabase + .from('store') + .insert(store.toMap()) + .select() + .single(); + return StoreModel.fromMap(response); + } catch (e) { + debugPrint('Creazione negozio fallita: $e'); + throw Exception('Creazione negozio fallita: $e'); + } + } + + Future createStaffMember(StaffMemberModel staff) async { + try { + final response = await _supabase + .from('staff_member') + .insert(staff.toMap()) + .select() + .single(); + final StaffMemberModel staffMember = StaffMemberModel.fromMap(response); + await _supabase.from('staff_in_stores').insert({ + 'staff_member_id': staffMember.id, + 'store_id': GetIt.I.get().state.currentStore!.id, + }); + return StaffMemberModel.fromMap(response); + } catch (e) { + debugPrint('Creazione profilo staff fallita: $e'); + throw Exception('Creazione profilo staff fallita: $e'); + } + } +} diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 3cab5bd..624a36c 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -1,67 +1,89 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; -import 'package:flux/core/blocs/session/session_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/data/core_repository.dart'; import 'package:flux/features/auth/ui/auth_screen.dart'; -import 'package:flux/features/company/ui/create_company_screen.dart'; import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/ui/customer_detail_screen.dart'; import 'package:flux/features/home/ui/home_screen.dart'; import 'package:flux/features/master_data/products/ui/products_screen.dart'; -import 'package:flux/features/master_data/store/ui/create_store_screen.dart'; +import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart'; +import 'package:flux/features/onboarding/ui/onboarding_screen.dart'; import 'package:flux/features/services/models/service_model.dart'; import 'package:flux/features/services/ui/service_form_screen/service_form_screen.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; -import 'dart:async'; class AppRouter { - // Funzione statica per creare il router - static GoRouter createRouter(SessionBloc sessionBloc) { + static GoRouter createRouter(SessionCubit sessionCubit) { return GoRouter( initialLocation: '/', - // Ascolta i cambiamenti del Bloc per scatenare il redirect - refreshListenable: _GoRouterRefreshStream(sessionBloc.stream), + // MAGIA 1: Il router "ascolta" ogni singolo respiro del SessionCubit + refreshListenable: GoRouterRefreshStream(sessionCubit.stream), + + // MAGIA 2: Il Buttafuori Supremo redirect: (context, state) { - final sessionState = sessionBloc.state; + final sessionState = sessionCubit.state; + final isGoingToLogin = state.matchedLocation == '/login'; + final isGoingToOnboarding = state.matchedLocation == '/onboarding'; - // Logica di redirezione basata sugli stati del SessionBloc - final bool isUnknown = sessionState.status == SessionStatus.unknown; - final bool isUnauthenticated = - sessionState.status == SessionStatus.unauthenticated; - final bool isNoCompany = - sessionState.status == SessionStatus.authenticatedNoCompany; - final bool isNoStore = - sessionState.status == SessionStatus.authenticatedNoStore; - final bool isReady = sessionState.status == SessionStatus.ready; - - final String location = state.matchedLocation; - - if (isUnknown) return null; // Aspetta che l'app si svegli - - if (isUnauthenticated && location != '/login') return '/login'; - - if (isNoCompany && location != '/create-company') { - return '/create-company'; + // Caso 1: L'app si sta ancora avviando. + // Restituiamo null per farlo rimanere sulla SplashScreen del main.dart + if (sessionState.status == SessionStatus.initial) { + return null; } - if (isNoStore && location != '/create-store') return '/create-store'; + // Caso 2: Utente NON loggato. + if (sessionState.status == SessionStatus.unauthenticated) { + // Se sta già andando al login, lascialo andare. Altrimenti, forzalo al login. + return isGoingToLogin ? null : '/login'; + } - // Se sono loggato e sto cercando di andare alla login, vai in dashboard - if (isReady && location == '/login') return '/'; + // Caso 3: Utente loggato MA manca un pezzo dell'azienda (Flusso Canalizzatore) + 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'; + } + + // Caso 4: Utente loggato e configurato (Tutto OK!) + if (sessionState.status == SessionStatus.authenticated) { + // Se per sbaglio cerca di tornare al login o all'onboarding, + // lo rimbalziamo alla home. + if (isGoingToLogin || isGoingToOnboarding) { + return '/'; + } + // Per tutte le altre rotte (dashboard, clienti, anagrafiche), lascialo passare. + return null; + } return null; }, routes: [ - GoRoute(path: '/', builder: (context, state) => const HomeScreen()), GoRoute( path: '/login', + //builder: (context, state) => const LoginScreen(), builder: (context, state) => const AuthScreen(), ), GoRoute( - path: '/create-company', - builder: (context, state) => const CreateCompanyScreen(), + path: '/onboarding', + builder: (context, state) => BlocProvider( + create: (context) => OnboardingCubit( + GetIt.I.get(), + GetIt.I.get(), + ), + 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! ), GoRoute( - path: '/create-store', - builder: (context, state) => const CreateStoreScreen(), + path: '/', + builder: (context, state) => const HomeScreen(), // La tua home ), GoRoute( path: '/customer/:id', @@ -96,11 +118,14 @@ class AppRouter { } } -// Classe di supporto per convertire lo Stream del Bloc in un Listenable per GoRouter -class _GoRouterRefreshStream extends ChangeNotifier { - _GoRouterRefreshStream(Stream stream) { +/// Utility fondamentale per GoRouter: trasforma lo Stream del Cubit +/// in un Listenable che GoRouter può ascoltare per forzare i redirect. +class GoRouterRefreshStream extends ChangeNotifier { + GoRouterRefreshStream(Stream stream) { notifyListeners(); - _subscription = stream.asBroadcastStream().listen((_) => notifyListeners()); + _subscription = stream.asBroadcastStream().listen( + (dynamic _) => notifyListeners(), + ); } late final StreamSubscription _subscription; diff --git a/lib/core/utils/validators.dart b/lib/core/utils/validators.dart new file mode 100644 index 0000000..c689a60 --- /dev/null +++ b/lib/core/utils/validators.dart @@ -0,0 +1,6 @@ +String? notEmptyValidator(String? value) { + if (value == null || value.trim().isEmpty) { + return 'Campo obbligatorio'; + } + return null; +} diff --git a/lib/core/widgets/flux_text_field.dart b/lib/core/widgets/flux_text_field.dart index a60afe5..a9bafae 100644 --- a/lib/core/widgets/flux_text_field.dart +++ b/lib/core/widgets/flux_text_field.dart @@ -1,10 +1,12 @@ // lib/ui/common/flux_text_field.dart import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flux/core/theme/theme.dart'; class FluxTextField extends StatefulWidget { final String label; - final IconData icon; + final String? labelText; + final IconData? icon; final bool isPassword; final bool autoFocus; final TextEditingController? controller; @@ -14,11 +16,16 @@ class FluxTextField extends StatefulWidget { final Function(String)? onSubmitted; final Function(String)? onChanged; final int? maxLength; + final String? Function(String?)? validator; + final List? inputFormatters; + final TextCapitalization? textCapitalization; + final bool? autocorrect; const FluxTextField({ super.key, // Usiamo super.key per Flutter moderno required this.label, - required this.icon, + this.labelText, + this.icon, this.isPassword = false, this.autoFocus = false, this.controller, @@ -28,6 +35,10 @@ class FluxTextField extends StatefulWidget { this.onSubmitted, this.onChanged, this.maxLength, + this.validator, + this.inputFormatters, + this.textCapitalization, + this.autocorrect, }); @override @@ -45,11 +56,13 @@ class _FluxTextFieldState extends State { @override Widget build(BuildContext context) { - return TextField( + return TextFormField( controller: widget.controller, + validator: widget.validator, obscureText: _obscureText, + enableSuggestions: !widget.isPassword, - autocorrect: !widget.isPassword, + autocorrect: widget.isPassword ? false : widget.autocorrect ?? true, keyboardType: widget.keyboardType, autofocus: widget.autoFocus, minLines: widget.minLines, @@ -57,11 +70,11 @@ class _FluxTextFieldState extends State { maxLines: widget.minLines != null ? null : widget.maxLines, style: TextStyle(color: context.primaryText), decoration: InputDecoration( - prefixIcon: Icon( - widget.icon, - color: context.accent.withValues(alpha: 0.6), - ), - labelText: widget.label, + prefixIcon: widget.icon != null + ? Icon(widget.icon, color: context.accent.withValues(alpha: 0.6)) + : null, + + labelText: widget.labelText ?? widget.label, labelStyle: TextStyle(color: context.secondaryText, fontSize: 14), filled: true, fillColor: context.surface.withValues(alpha: 0.5), @@ -79,6 +92,7 @@ class _FluxTextFieldState extends State { horizontal: 16, vertical: 16, ), + suffixIcon: widget.isPassword ? IconButton( icon: Icon( @@ -95,9 +109,12 @@ class _FluxTextFieldState extends State { ) : null, // Se non è una password, niente icona ), - onSubmitted: widget.onSubmitted, + onFieldSubmitted: widget.onSubmitted, onChanged: widget.onChanged, maxLength: widget.maxLength, + inputFormatters: widget.inputFormatters, + + textCapitalization: widget.textCapitalization ?? TextCapitalization.none, ); } } diff --git a/lib/features/auth/bloc/auth_bloc.dart b/lib/features/auth/bloc/auth_bloc.dart deleted file mode 100644 index 202136b..0000000 --- a/lib/features/auth/bloc/auth_bloc.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:get_it/get_it.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -part 'auth_events.dart'; -part 'auth_state.dart'; - -class AuthBloc extends Bloc { - final _supabase = GetIt.instance(); - - AuthBloc() - : super(const AuthState(status: AuthStatus.initial, isLoginMode: true)) { - on( - (event, emit) => emit(state.copyWith(isLoginMode: !state.isLoginMode)), - ); - on((event, emit) async { - emit(state.copyWith(status: AuthStatus.loading)); - try { - if (state.isLoginMode) { - // --- LOGICA LOGIN --- - await _supabase.auth.signInWithPassword( - email: event.email, - password: event.password, - ); - // Non serve emettere success qui, ci pensa il SessionBloc! - } else { - // --- LOGICA SIGNUP --- - await _supabase.auth.signUp( - email: event.email, - password: event.password, - ); - - // Nota: Se Supabase richiede conferma email, l'utente non sarà - // loggato subito. Gestiamolo con un messaggio. - emit( - state.copyWith( - status: AuthStatus.success, - error: "Controlla la tua email per confermare l'account!", - ), - ); - } - } on AuthException catch (e) { - emit(state.copyWith(status: AuthStatus.failure, error: e.message)); - } catch (e) { - emit( - state.copyWith( - status: AuthStatus.failure, - error: "Errore imprevisto: $e", - ), - ); - } - }); - on((event, emit) async { - await _supabase.auth.signOut(); - }); - } -} diff --git a/lib/features/auth/bloc/auth_cubit.dart b/lib/features/auth/bloc/auth_cubit.dart new file mode 100644 index 0000000..6f062fe --- /dev/null +++ b/lib/features/auth/bloc/auth_cubit.dart @@ -0,0 +1,71 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:get_it/get_it.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +part 'auth_state.dart'; + +class AuthCubit extends Cubit { + final _supabase = GetIt.instance(); + + AuthCubit() : super(const AuthState()); + + void toggleMode() { + emit(state.copyWith(isLoginMode: !state.isLoginMode)); + } + + Future submitAuth(String email, String password) async { + // Partiamo puliti: via vecchi messaggi ed errori + emit(state.copyWith(status: AuthStatus.loading)); + + try { + if (state.isLoginMode) { + // --- LOGICA LOGIN --- + await _supabase.auth.signInWithPassword( + email: email, + password: password, + ); + // NESSUN EMIT DI SUCCESS! + // Supabase lancerà l'evento 'signedIn', il SessionCubit lo catturerà + // e il GoRouter ci cambierà pagina. Noi stiamo a guardare il caricamento. + } else { + // --- LOGICA SIGNUP --- + final AuthResponse res = await _supabase.auth.signUp( + email: email, + password: password, + ); + + if (res.session == null) { + // Caso: Conferma Email attivata su Supabase + emit( + state.copyWith( + status: AuthStatus.initial, + infoMessage: "Controlla la tua email per confermare l'account!", + ), + ); + } else { + // Caso: Autologin post-registrazione (Conferma email disattivata) + // 1. Fermiamo il frullino! + emit(state.copyWith(status: AuthStatus.initial)); + // 2. Svegliamo il SessionCubit! + GetIt.I().initializeSession(); + } + // Se non è null, ha fatto il login automatico. Stessa cosa di sopra, ci pensa il SessionCubit. + } + } on AuthException catch (e) { + emit(state.copyWith(status: AuthStatus.failure, errorMessage: e.message)); + } catch (e) { + emit( + state.copyWith( + status: AuthStatus.failure, + errorMessage: "Errore imprevisto: $e", + ), + ); + } + } + + Future requestLogout() async { + await _supabase.auth.signOut(); + emit(state.copyWith(status: AuthStatus.initial)); + } +} diff --git a/lib/features/auth/bloc/auth_events.dart b/lib/features/auth/bloc/auth_events.dart deleted file mode 100644 index da08631..0000000 --- a/lib/features/auth/bloc/auth_events.dart +++ /dev/null @@ -1,21 +0,0 @@ -part of 'auth_bloc.dart'; - -abstract class AuthEvent extends Equatable { - const AuthEvent(); - - @override - List get props => []; -} - -class ToggleAuthMode extends AuthEvent {} // Passa da Login a Registrazione - -class LoginRequested extends AuthEvent { - final String email; - final String password; - const LoginRequested({required this.email, required this.password}); - - @override - List get props => [email, password]; -} - -class LogoutRequested extends AuthEvent {} // Logout diff --git a/lib/features/auth/bloc/auth_state.dart b/lib/features/auth/bloc/auth_state.dart index 2fb1a87..e491094 100644 --- a/lib/features/auth/bloc/auth_state.dart +++ b/lib/features/auth/bloc/auth_state.dart @@ -1,26 +1,35 @@ -part of 'auth_bloc.dart'; +part of 'auth_cubit.dart'; -enum AuthStatus { initial, loading, success, failure } +enum AuthStatus { initial, loading, failure } class AuthState extends Equatable { + final AuthStatus status; + final bool isLoginMode; + final String? errorMessage; + final String? infoMessage; + const AuthState({ - required this.status, - this.error, - required this.isLoginMode, + this.status = AuthStatus.initial, + this.isLoginMode = true, + this.errorMessage, + this.infoMessage, }); - final AuthStatus status; - final String? error; - final bool isLoginMode; - - @override - List get props => [status, error, isLoginMode]; - - AuthState copyWith({AuthStatus? status, String? error, bool? isLoginMode}) { + AuthState copyWith({ + AuthStatus? status, + bool? isLoginMode, + String? errorMessage, + String? infoMessage, + }) { return AuthState( status: status ?? this.status, - error: error, isLoginMode: isLoginMode ?? this.isLoginMode, + // Se non passo esplicitamente un errore, lo resetto per evitare che rimanga bloccato a schermo + errorMessage: errorMessage, + infoMessage: infoMessage, ); } + + @override + List get props => [status, isLoginMode, errorMessage, infoMessage]; } diff --git a/lib/features/auth/ui/auth_screen.dart b/lib/features/auth/ui/auth_screen.dart index 96a37e3..00543d9 100644 --- a/lib/features/auth/ui/auth_screen.dart +++ b/lib/features/auth/ui/auth_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/widgets/flux_logo.dart'; import 'package:flux/core/widgets/flux_text_field.dart'; -import 'package:flux/features/auth/bloc/auth_bloc.dart'; +import 'package:flux/features/auth/bloc/auth_cubit.dart'; class AuthScreen extends StatefulWidget { const AuthScreen({super.key}); @@ -15,7 +15,6 @@ class AuthScreen extends StatefulWidget { class _AuthScreenState extends State { final _emailController = TextEditingController(); final _passwordController = TextEditingController(); - final _isPassword = true; @override void dispose() { @@ -24,19 +23,43 @@ class _AuthScreenState extends State { super.dispose(); } + void _submit() { + // Chiudiamo la tastiera per fare pulizia a schermo + FocusScope.of(context).unfocus(); + + context.read().submitAuth( + _emailController.text.trim(), + _passwordController.text.trim(), + ); + } + @override Widget build(BuildContext context) { return Scaffold( - body: BlocConsumer( + body: BlocConsumer( + // Ottimizzazione: Ridisegniamo la UI solo quando cambia lo status o la modalità + listenWhen: (previous, current) => + previous.errorMessage != current.errorMessage || + previous.infoMessage != current.infoMessage, listener: (context, state) { - if (state.status == AuthStatus.failure) { + // Mostriamo l'errore se c'è + if (state.errorMessage != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(state.error ?? 'Errore di autenticazione'), + content: Text(state.errorMessage!), backgroundColor: Colors.redAccent, ), ); } + // Mostriamo il messaggio info (es. Conferma Email) + if (state.infoMessage != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.infoMessage!), + backgroundColor: Colors.blueAccent, // O context.accent + ), + ); + } }, builder: (context, state) { final isLoading = state.status == AuthStatus.loading; @@ -49,7 +72,7 @@ class _AuthScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ // --- LOGO FLUX --- - FluxLogoAuto(height: 80), + const FluxLogoAuto(height: 80), const SizedBox(height: 60), // --- TITOLO DINAMICO --- @@ -83,9 +106,10 @@ class _AuthScreenState extends State { FluxTextField( label: 'Password', icon: Icons.lock_outline, - isPassword: true, + isPassword: true, // Magia del FluxTextField! controller: _passwordController, - onSubmitted: (_) => _submit(), + onSubmitted: (_) => + _submit(), // Se lo supporti nel tuo widget custom ), const SizedBox(height: 40), @@ -95,7 +119,7 @@ class _AuthScreenState extends State { width: double.infinity, height: 56, child: ElevatedButton( - onPressed: isLoading ? null : () => _submit(), + onPressed: isLoading ? null : _submit, child: isLoading ? const SizedBox( height: 24, @@ -105,7 +129,12 @@ class _AuthScreenState extends State { color: Colors.white, ), ) - : Text(state.isLoginMode ? 'ACCEDI' : 'REGISTRATI'), + : Text( + state.isLoginMode ? 'ACCEDI' : 'REGISTRATI', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), ), ), @@ -114,9 +143,7 @@ class _AuthScreenState extends State { TextButton( onPressed: isLoading ? null - : () { - context.read().add(ToggleAuthMode()); - }, + : () => context.read().toggleMode(), child: RichText( text: TextSpan( text: state.isLoginMode @@ -144,13 +171,4 @@ class _AuthScreenState extends State { ), ); } - - void _submit() { - context.read().add( - LoginRequested( - email: _emailController.text.trim(), - password: _passwordController.text.trim(), - ), - ); - } } diff --git a/lib/features/company/data/company_repository.dart b/lib/features/company/data/company_repository.dart index 8416a87..2c70f4f 100644 --- a/lib/features/company/data/company_repository.dart +++ b/lib/features/company/data/company_repository.dart @@ -9,11 +9,11 @@ class CompanyRepository { // .select().single() trasforma la risposta nell'oggetto appena inserito final response = await _supabase .from('company') - .insert(company.toJson()) + .insert(company.toMap()) .select() .single(); - return CompanyModel.fromJson(response); + return CompanyModel.fromMap(response); } on PostgrestException catch (e) { throw e.message; } catch (e) { @@ -30,7 +30,7 @@ class CompanyRepository { .eq('user_id', userId as Object) .maybeSingle(); - return response != null ? CompanyModel.fromJson(response) : null; + return response != null ? CompanyModel.fromMap(response) : null; } catch (e) { return null; } diff --git a/lib/features/company/models/company_model.dart b/lib/features/company/models/company_model.dart index 332bae3..7cc8013 100644 --- a/lib/features/company/models/company_model.dart +++ b/lib/features/company/models/company_model.dart @@ -1,9 +1,50 @@ import 'package:equatable/equatable.dart'; +// =================================================================== +// ENUMS (Paranoia Mode per la lettura dal DB) +// =================================================================== + +enum SubscriptionTier { + free, + pro, + premium, + platinum; + + static SubscriptionTier fromString(String? value) { + return SubscriptionTier.values.firstWhere( + (e) => e.name == value, + orElse: () => SubscriptionTier.free, // Fallback di sicurezza + ); + } +} + +enum SubscriptionStatus { + trialing, + active, + pastDue, + canceled; + + static SubscriptionStatus fromString(String? value) { + if (value == null) return SubscriptionStatus.canceled; + // Normalizziamo 'past_due' dal DB in 'pastdue' per il match con l'Enum + final normalizedValue = value.replaceAll('_', '').toLowerCase(); + return SubscriptionStatus.values.firstWhere( + (e) => e.name.toLowerCase() == normalizedValue, + orElse: () => SubscriptionStatus.canceled, + ); + } +} + +// =================================================================== +// IL MODELLO ESATTO +// =================================================================== + class CompanyModel extends Equatable { - final String id; + final String? id; final DateTime? createdAt; - final String userId; + final String userId; // Nel DB è user_id (chiave esterna su auth.users) + + // Dati Anagrafici e Fatturazione final String ragioneSociale; final String indirizzo; final String cap; @@ -12,12 +53,21 @@ class CompanyModel extends Equatable { final String partitaIva; final String codiceFiscale; final String codiceUnivoco; - final bool isPaid; - final DateTime? paymentExpiration; final String companyLogo; + // Stato Pagamenti (Ibride: manuale + Stripe) + final bool isPaid; + final DateTime? paymentExpiration; + + // Campi SaaS Stripe/Automazioni + final SubscriptionTier subscriptionTier; + final SubscriptionStatus subscriptionStatus; + final DateTime? trialEndsAt; + final String? stripeCustomerId; + final String? stripeSubscriptionId; + const CompanyModel({ - this.id = '', + this.id, this.createdAt, required this.userId, required this.ragioneSociale, @@ -28,48 +78,16 @@ class CompanyModel extends Equatable { required this.partitaIva, required this.codiceFiscale, required this.codiceUnivoco, + this.companyLogo = '', this.isPaid = false, this.paymentExpiration, - this.companyLogo = '', + this.subscriptionTier = SubscriptionTier.free, + this.subscriptionStatus = SubscriptionStatus.trialing, + this.trialEndsAt, + this.stripeCustomerId, + this.stripeSubscriptionId, }); - factory CompanyModel.fromJson(Map json) { - return CompanyModel( - id: json['id'] as String, - createdAt: DateTime.parse(json['created_at']), - userId: json['user_id'] as String, - ragioneSociale: json['ragione_sociale'], - indirizzo: json['indirizzo'], - cap: json['cap'], - citta: json['citta'], - provincia: json['provincia'], - partitaIva: json['partita_iva'], - codiceFiscale: json['codice_fiscale'], - codiceUnivoco: json['codice_univoco'], - isPaid: json['is_paid'] ?? false, - paymentExpiration: json['payment_expiration'] != null - ? DateTime.parse(json['payment_expiration']) - : null, - companyLogo: json['company_logo'] ?? '', - ); - } - - Map toJson() { - return { - 'ragione_sociale': ragioneSociale, - 'indirizzo': indirizzo, - 'cap': cap, - 'citta': citta, - 'provincia': provincia, - 'partita_iva': partitaIva, - 'codice_fiscale': codiceFiscale, - 'codice_univoco': codiceUnivoco, - 'is_paid': isPaid, - 'payment_expiration': paymentExpiration?.toIso8601String(), - 'company_logo': companyLogo, - }; - } - CompanyModel copyWith({ String? id, DateTime? createdAt, @@ -82,26 +100,214 @@ class CompanyModel extends Equatable { String? partitaIva, String? codiceFiscale, String? codiceUnivoco, + String? companyLogo, bool? isPaid, DateTime? paymentExpiration, - String? companyLogo, - }) => CompanyModel( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - userId: userId ?? this.userId, - ragioneSociale: ragioneSociale ?? this.ragioneSociale, - indirizzo: indirizzo ?? this.indirizzo, - cap: cap ?? this.cap, - citta: citta ?? this.citta, - provincia: provincia ?? this.provincia, - partitaIva: partitaIva ?? this.partitaIva, - codiceFiscale: codiceFiscale ?? this.codiceFiscale, - codiceUnivoco: codiceUnivoco ?? this.codiceUnivoco, - isPaid: isPaid ?? this.isPaid, - paymentExpiration: paymentExpiration ?? this.paymentExpiration, - companyLogo: companyLogo ?? this.companyLogo, - ); + SubscriptionTier? subscriptionTier, + SubscriptionStatus? subscriptionStatus, + DateTime? trialEndsAt, + String? stripeCustomerId, + String? stripeSubscriptionId, + }) { + return CompanyModel( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + userId: userId ?? this.userId, + ragioneSociale: ragioneSociale ?? this.ragioneSociale, + indirizzo: indirizzo ?? this.indirizzo, + cap: cap ?? this.cap, + citta: citta ?? this.citta, + provincia: provincia ?? this.provincia, + partitaIva: partitaIva ?? this.partitaIva, + codiceFiscale: codiceFiscale ?? this.codiceFiscale, + codiceUnivoco: codiceUnivoco ?? this.codiceUnivoco, + companyLogo: companyLogo ?? this.companyLogo, + isPaid: isPaid ?? this.isPaid, + paymentExpiration: paymentExpiration ?? this.paymentExpiration, + subscriptionTier: subscriptionTier ?? this.subscriptionTier, + subscriptionStatus: subscriptionStatus ?? this.subscriptionStatus, + trialEndsAt: trialEndsAt ?? this.trialEndsAt, + stripeCustomerId: stripeCustomerId ?? this.stripeCustomerId, + stripeSubscriptionId: stripeSubscriptionId ?? this.stripeSubscriptionId, + ); + } + + factory CompanyModel.empty() { + return const CompanyModel( + id: null, + createdAt: null, + userId: '', + ragioneSociale: '', + indirizzo: '', + cap: '', + citta: '', + provincia: '', + partitaIva: '', + codiceFiscale: '', + codiceUnivoco: '', + ); + } + + factory CompanyModel.fromMap(Map map) { + return CompanyModel( + id: map['id'] as String?, + createdAt: map['created_at'] != null + ? DateTime.tryParse(map['created_at']) + : null, + userId: map['user_id'] ?? '', + ragioneSociale: map['ragione_sociale'] ?? '', + indirizzo: map['indirizzo'] ?? '', + cap: map['cap'] ?? '', + citta: map['citta'] ?? '', + provincia: map['provincia'] ?? '', + partitaIva: map['partita_iva'] ?? '', + codiceFiscale: map['codice_fiscale'] ?? '', + codiceUnivoco: map['codice_univoco'] ?? '', + companyLogo: map['company_logo'] ?? '', + isPaid: map['is_paid'] ?? false, + paymentExpiration: map['payment_expiration'] != null + ? DateTime.tryParse(map['payment_expiration']) + : null, + subscriptionTier: SubscriptionTier.fromString(map['subscription_tier']), + subscriptionStatus: SubscriptionStatus.fromString( + map['subscription_status'], + ), + trialEndsAt: map['trial_ends_at'] != null + ? DateTime.tryParse(map['trial_ends_at']) + : null, + stripeCustomerId: map['stripe_customer_id'], + stripeSubscriptionId: map['stripe_subscription_id'], + ); + } + + Map toMap() { + return { + if (id != null) 'id': id, + // created_at è gestito dal DB di default, di solito non si passa nell'insert + 'user_id': userId, + 'ragione_sociale': ragioneSociale, + 'indirizzo': indirizzo, + 'cap': cap, + 'citta': citta, + 'provincia': provincia, + 'partita_iva': partitaIva, + 'codice_fiscale': codiceFiscale, + 'codice_univoco': codiceUnivoco, + 'company_logo': companyLogo, + 'is_paid': isPaid, + if (paymentExpiration != null) + 'payment_expiration': paymentExpiration!.toIso8601String(), + 'subscription_tier': subscriptionTier.name, + 'subscription_status': subscriptionStatus == SubscriptionStatus.pastDue + ? 'past_due' + : subscriptionStatus.name, + if (trialEndsAt != null) 'trial_ends_at': trialEndsAt!.toIso8601String(), + if (stripeCustomerId != null) 'stripe_customer_id': stripeCustomerId, + if (stripeSubscriptionId != null) + 'stripe_subscription_id': stripeSubscriptionId, + }; + } @override - List get props => [id, userId, ragioneSociale, partitaIva, isPaid]; + List get props => [ + id, + createdAt, + userId, + ragioneSociale, + indirizzo, + cap, + citta, + provincia, + partitaIva, + codiceFiscale, + codiceUnivoco, + companyLogo, + isPaid, + paymentExpiration, + subscriptionTier, + subscriptionStatus, + trialEndsAt, + stripeCustomerId, + stripeSubscriptionId, + ]; +} + +// =================================================================== +// BUSINESS LOGIC: I LIMITI DEI TIER +// =================================================================== + +extension CompanyLimits on CompanyModel { + int get maxStores { + switch (subscriptionTier) { + case SubscriptionTier.free: + return 1; + case SubscriptionTier.pro: + return 1; + case SubscriptionTier.premium: + return 10; + case SubscriptionTier.platinum: + return 30; + } + } + + int get maxStaffMembers { + switch (subscriptionTier) { + case SubscriptionTier.free: + return 2; + case SubscriptionTier.pro: + return 10; + case SubscriptionTier.premium: + return 50; + case SubscriptionTier.platinum: + return 150; + } + } + + int get maxServicesPerMonth { + switch (subscriptionTier) { + case SubscriptionTier.free: + return 50; + case SubscriptionTier.pro: + return 1000; + case SubscriptionTier.premium: + return 10000; + case SubscriptionTier.platinum: + return 30000; + } + } + + int get maxStorageGb { + switch (subscriptionTier) { + case SubscriptionTier.free: + return 0; // 500MB = 0.5 GB, magari qui usi i MB interi + case SubscriptionTier.pro: + return 3; + case SubscriptionTier.premium: + return 30; + case SubscriptionTier.platinum: + return 100; + } + } + + /// Verifica generale: L'utente ha i permessi per usare l'app? + bool get hasActiveAccess { + // 1. Priorità all'override manuale (is_paid e payment_expiration) + if (isPaid) { + if (paymentExpiration == null) + return true; // Pagato "a vita" o senza scadenza + if (DateTime.now().isBefore(paymentExpiration!)) return true; + } + + // 2. Controllo SaaS (Stripe/Subscription) + if (subscriptionStatus == SubscriptionStatus.active) return true; + + // 3. Controllo Trial + if (subscriptionStatus == SubscriptionStatus.trialing && + trialEndsAt != null) { + return DateTime.now().isBefore(trialEndsAt!); + } + + // Scaduto, past_due, o cancellato + return false; + } } diff --git a/lib/features/company/ui/create_company_screen.dart b/lib/features/company/ui/create_company_screen.dart index 255e2a0..0127f0f 100644 --- a/lib/features/company/ui/create_company_screen.dart +++ b/lib/features/company/ui/create_company_screen.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/features/auth/bloc/auth_bloc.dart'; +import 'package:flux/features/auth/bloc/auth_cubit.dart'; import 'package:flux/features/company/bloc/company_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/features/company/models/company_model.dart'; @@ -46,7 +46,7 @@ class _CreateCompanyScreenState extends State { void _onSave() { if (_formKey.currentState!.validate()) { // Recuperiamo l'ID utente attuale da Supabase o dal SessionBloc - final userId = context.read().state.userId!; + final userId = context.read().state.user!.id; final company = CompanyModel( userId: userId, @@ -77,7 +77,7 @@ class _CreateCompanyScreenState extends State { onPressed: () { // Qui chiami il tuo Bloc dell'autenticazione per fare logout // Esempio se hai un AuthBloc o SessionBloc: - context.read().add(LogoutRequested()); + //context.read().add(LogoutRequested()); // Se vuoi solo tornare brutalmente alla login per testare il logo: // Navigator.of(context).pushReplacementNamed('/login'); @@ -92,7 +92,7 @@ class _CreateCompanyScreenState extends State { //GetIt.I.get().setCurrentCompany(state.company); // 2. Notifichiamo il SessionBloc per cambiare pagina - context.read().add(AppStarted()); + //context.read().add(AppStarted()); } if (state.status == CompanyStatus.failure) { diff --git a/lib/features/customers/blocs/customer_cubit.dart b/lib/features/customers/blocs/customer_cubit.dart index 7f39674..64f4882 100644 --- a/lib/features/customers/blocs/customer_cubit.dart +++ b/lib/features/customers/blocs/customer_cubit.dart @@ -1,7 +1,7 @@ import 'dart:async'; // Serve per il Timer del debounce import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/features/customers/models/customer_model.dart'; import 'package:get_it/get_it.dart'; @@ -10,7 +10,7 @@ part 'customer_state.dart'; class CustomerCubit extends Cubit { final CustomerRepository _repository = GetIt.I(); - final SessionBloc _sessionBloc = GetIt.I(); + final SessionCubit _sessionCubit = GetIt.I(); // Variabile per gestire il debounce della ricerca Timer? _searchDebounce; @@ -22,7 +22,7 @@ class CustomerCubit extends Cubit { emit(state.copyWith(status: CustomerStatus.loading)); try { final customers = await _repository.getCustomers( - _sessionBloc.state.company!.id, + _sessionCubit.state.company!.id!, ); emit( state.copyWith(status: CustomerStatus.success, customers: customers), @@ -111,7 +111,7 @@ class CustomerCubit extends Cubit { // Nessun "loading" state qui, per evitare sfarfallii visivi mentre si scrive try { final results = await _repository.searchCustomers( - _sessionBloc.state.company!.id, + _sessionCubit.state.company!.id!, query, ); emit( @@ -137,7 +137,7 @@ class CustomerCubit extends Cubit { nome: name, telefono: phone ?? '', email: email ?? '', - companyId: _sessionBloc.state.company!.id, + companyId: _sessionCubit.state.company!.id!, note: '', ); diff --git a/lib/features/customers/data/customer_repository.dart b/lib/features/customers/data/customer_repository.dart index ddbef62..6b3f3c2 100644 --- a/lib/features/customers/data/customer_repository.dart +++ b/lib/features/customers/data/customer_repository.dart @@ -1,5 +1,5 @@ import 'package:file_picker/file_picker.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:get_it/get_it.dart'; @@ -8,7 +8,7 @@ import '../models/customer_model.dart'; class CustomerRepository { final SupabaseClient _supabase = GetIt.I(); - final String companyId = GetIt.I.get().state.company!.id; + final String companyId = GetIt.I.get().state.company!.id!; // Crea un nuovo cliente Future saveCustomer(CustomerModel customer) async { diff --git a/lib/features/customers/ui/customers_content.dart b/lib/features/customers/ui/customers_content.dart index e7c22dd..d9eb0b1 100644 --- a/lib/features/customers/ui/customers_content.dart +++ b/lib/features/customers/ui/customers_content.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/features/customers/blocs/customer_cubit.dart'; import 'package:flux/features/customers/models/customer_model.dart'; @@ -24,14 +24,14 @@ class _CustomersContentState extends State { } void _loadInitialCustomers() { - final companyId = context.read().state.company?.id; + final companyId = context.read().state.company?.id; if (companyId != null) { context.read().loadCustomers(); } } void _onSearch(String query) { - final companyId = context.read().state.company?.id; + final companyId = context.read().state.company?.id; if (companyId != null) { context.read().searchCustomers(query); } @@ -48,7 +48,7 @@ class _CustomersContentState extends State { child: CustomerForm( customer: customer, onSave: (customerFromForm) { - final session = context.read().state; + final session = context.read().state; final companyId = session.company?.id; if (companyId == null) return; diff --git a/lib/features/home/ui/dashboard_content.dart b/lib/features/home/ui/dashboard_content.dart index 997f5ff..d32b204 100644 --- a/lib/features/home/ui/dashboard_content.dart +++ b/lib/features/home/ui/dashboard_content.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/features/home/ui/dashboard_adaptive_grid.dart'; @@ -16,9 +16,9 @@ class DashboardContent extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { - final store = state.selectedStore; + final store = state.currentStore; final company = state.company; return Scaffold( diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index fbde4fd..bf11ead 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/theme/theme.dart'; -import 'package:flux/features/auth/bloc/auth_bloc.dart'; +import 'package:flux/features/auth/bloc/auth_cubit.dart'; import 'package:flux/features/master_data/master_data_hub_content.dart'; import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/services/ui/services_screen.dart'; @@ -30,7 +30,7 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { return LayoutBuilder( builder: (context, constraints) { @@ -203,7 +203,7 @@ class _HomeScreenState extends State { ), const SizedBox(width: 12), Text( - GetIt.I.get().state.company?.ragioneSociale ?? + GetIt.I.get().state.company?.ragioneSociale ?? "Utente", style: TextStyle( fontWeight: FontWeight.bold, @@ -246,9 +246,7 @@ class _HomeScreenState extends State { ), onPressed: () { Navigator.pop(dialogContext); // Chiude la Dialog - context.read().add( - LogoutRequested(), - ); // Esegue il logout + context.read().requestLogout(); // Esegue il logout }, child: const Text("Esci"), ), diff --git a/lib/features/master_data/products/blocs/product_cubit.dart b/lib/features/master_data/products/blocs/product_cubit.dart index b290a2a..b35a6bc 100644 --- a/lib/features/master_data/products/blocs/product_cubit.dart +++ b/lib/features/master_data/products/blocs/product_cubit.dart @@ -1,7 +1,7 @@ import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/features/master_data/products/data/product_repository.dart'; import 'package:flux/features/master_data/products/models/brand_model.dart'; import 'package:flux/features/master_data/products/models/model_model.dart'; @@ -11,7 +11,7 @@ part 'product_state.dart'; class ProductCubit extends Cubit { final ProductRepository _repository = GetIt.I(); - final SessionBloc _sessionBloc = GetIt.I(); + final SessionCubit _sessionCubit = GetIt.I(); ProductCubit() : super(const ProductState()); @@ -20,7 +20,7 @@ class ProductCubit extends Cubit { emit(state.copyWith(status: ProductStatus.loading)); try { final brands = await _repository.getBrands( - _sessionBloc.state.company!.id, + _sessionCubit.state.company!.id!, ); emit(state.copyWith(status: ProductStatus.success, brands: brands)); } catch (e) { @@ -54,7 +54,7 @@ class ProductCubit extends Cubit { final brand = BrandModel( id: id, name: name, - companyId: _sessionBloc.state.company!.id, + companyId: _sessionCubit.state.company!.id!, ); final newBrand = await _repository.upsertBrand(brand); await loadBrands(); // Ricarichiamo la lista aggiornata @@ -137,7 +137,10 @@ class ProductCubit extends Cubit { // 1. Cerchiamo o creiamo il Brand // (Usa una funzione upsert o una ricerca rapida nel repository) brand ??= await _repository.upsertBrand( - BrandModel(name: brandName, companyId: _sessionBloc.state.company!.id), + BrandModel( + name: brandName, + companyId: _sessionCubit.state.company!.id!, + ), ); // 2. Creiamo il Modello legato al Brand diff --git a/lib/features/master_data/providers/blocs/provider_cubit.dart b/lib/features/master_data/providers/blocs/provider_cubit.dart index 0a7ee5b..8ca498c 100644 --- a/lib/features/master_data/providers/blocs/provider_cubit.dart +++ b/lib/features/master_data/providers/blocs/provider_cubit.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/features/master_data/providers/data/provider_repository.dart'; import 'package:flux/features/master_data/store/models/store_model.dart'; import 'package:get_it/get_it.dart'; @@ -52,7 +52,7 @@ class ProvidersState extends Equatable { class ProvidersCubit extends Cubit { final ProviderRepository _repository = GetIt.I(); - final SessionBloc _sessionBloc = GetIt.I(); + final SessionCubit _sessionCubit = GetIt.I(); ProvidersCubit() : super(const ProvidersState()); @@ -61,7 +61,7 @@ class ProvidersCubit extends Cubit { emit(state.copyWith(isLoading: true)); try { final all = await _repository.fetchAllCompanyProviders( - _sessionBloc.state.company!.id, + _sessionCubit.state.company!.id!, ); List associated = []; @@ -135,7 +135,7 @@ class ProvidersCubit extends Cubit { ) async { emit(state.copyWith(isLoading: true)); // Assicuriamoci di settare la companyId prima di salvare - provider = provider.copyWith(companyId: _sessionBloc.state.company!.id); + provider = provider.copyWith(companyId: _sessionCubit.state.company!.id); try { // 1. Salviamo l'anagrafica (upsert) // Se è un nuovo provider, l'ID potrebbe essere generato qui dal DB diff --git a/lib/features/master_data/staff/blocs/staff_cubit.dart b/lib/features/master_data/staff/blocs/staff_cubit.dart index 64a14b9..9c37a93 100644 --- a/lib/features/master_data/staff/blocs/staff_cubit.dart +++ b/lib/features/master_data/staff/blocs/staff_cubit.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/features/master_data/staff/data/staff_repository.dart'; import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/master_data/store/models/store_model.dart'; @@ -10,7 +10,7 @@ part 'staff_state.dart'; class StaffCubit extends Cubit { final StaffRepository _repository = GetIt.I.get(); - final SessionBloc _sessionBloc = GetIt.I(); + final SessionCubit _sessionCubit = GetIt.I(); StaffCubit() : super(const StaffState()); @@ -19,7 +19,7 @@ class StaffCubit extends Cubit { emit(state.copyWith(isLoading: true, error: null)); try { final staff = await _repository.getStaffMembers( - _sessionBloc.state.company!.id, + _sessionCubit.state.company!.id!, ); final Map> storesByStaff = {}; for (StaffMemberModel member in staff) { diff --git a/lib/features/master_data/staff/models/staff_member_model.dart b/lib/features/master_data/staff/models/staff_member_model.dart index 46ea409..1368716 100644 --- a/lib/features/master_data/staff/models/staff_member_model.dart +++ b/lib/features/master_data/staff/models/staff_member_model.dart @@ -1,47 +1,119 @@ import 'package:equatable/equatable.dart'; -import 'package:flux/core/utils/string_extensions.dart'; // Assicurati che il percorso sia corretto + +// L'Enum magico e blindato per il sistema +enum SystemRole { + admin, + manager, + user; + + // Helper per convertire dal DB a Dart in sicurezza + static SystemRole fromString(String? value) { + return SystemRole.values.firstWhere( + (e) => e.name == value, + orElse: () => SystemRole.user, // Fallback paranoico al livello più basso + ); + } +} class StaffMemberModel extends Equatable { final String? id; - final String name; - final String email; - final String phone; - final bool isActive; final String companyId; + final String userId; + final String name; + final String? email; + final String? phoneNumber; + final String? jobTitle; + final SystemRole systemRole; + final bool isActive; const StaffMemberModel({ this.id, - required this.name, - this.email = '', - this.phone = '', - this.isActive = true, required this.companyId, + required this.userId, + required this.name, + this.email, + this.phoneNumber, + this.jobTitle, + this.systemRole = SystemRole.user, + this.isActive = true, }); + StaffMemberModel copyWith({ + String? id, + String? companyId, + String? userId, + String? name, + String? surname, + String? email, + String? phoneNumber, + String? jobTitle, + SystemRole? systemRole, + bool? isActive, + }) { + return StaffMemberModel( + id: id ?? this.id, + companyId: companyId ?? this.companyId, + userId: userId ?? this.userId, + name: name ?? this.name, + email: email ?? this.email, + phoneNumber: phoneNumber ?? this.phoneNumber, + jobTitle: jobTitle ?? this.jobTitle, + systemRole: systemRole ?? this.systemRole, + isActive: isActive ?? this.isActive, + ); + } + + factory StaffMemberModel.empty() { + return const StaffMemberModel( + companyId: '', + userId: '', + name: '', + email: '', + phoneNumber: '', + jobTitle: '', + systemRole: SystemRole.user, + isActive: true, + ); + } + factory StaffMemberModel.fromMap(Map map) { return StaffMemberModel( - id: map['id'], - // Applichiamo il tuo myFormat per visualizzare i nomi correttamente - name: (map['name'] as String).myFormat(), - // L'email la teniamo lowercase per standard tecnico - email: (map['email'] as String? ?? '').toLowerCase().trim(), - phone: (map['phone'] as String? ?? '').trim(), + id: map['id'] as String?, + companyId: map['company_id'] ?? '', + userId: map['user_id'] ?? '', + name: map['name'] ?? '', + email: map['email'] as String?, + phoneNumber: map['phone_number'] as String?, + jobTitle: map['job_title'] as String?, + systemRole: SystemRole.fromString(map['system_role']), isActive: map['is_active'] ?? true, - companyId: map['company_id'], ); } Map toMap() { return { if (id != null) 'id': id, - 'name': name.toLowerCase().trim(), // Salviamo pulito per le query - 'email': email.toLowerCase().trim(), - 'phone': phone.trim(), - 'is_active': isActive, 'company_id': companyId, + 'user_id': userId, + 'name': name, + if (email != null) 'email': email, + if (phoneNumber != null) 'phone_number': phoneNumber, + if (jobTitle != null) 'job_title': jobTitle, + 'system_role': systemRole.name, // Trasforma SystemRole.admin in 'admin' + 'is_active': isActive, }; } @override - List get props => [id, name, email, phone, isActive, companyId]; + List get props => [ + id, + companyId, + userId, + name, + email, + phoneNumber, + jobTitle, + systemRole, + isActive, + ]; } diff --git a/lib/features/master_data/staff/ui/staff_screen.dart b/lib/features/master_data/staff/ui/staff_screen.dart index a8c0a93..bb70842 100644 --- a/lib/features/master_data/staff/ui/staff_screen.dart +++ b/lib/features/master_data/staff/ui/staff_screen.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; // Tuo percorso @@ -135,8 +135,13 @@ class _StaffScreenState extends State { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (member.email.isNotEmpty) Text(member.email), - Text(member.phone.isNotEmpty ? member.phone : "Nessun telefono"), + if (member.email != null && member.email!.isNotEmpty) + Text(member.email!), + Text( + member.phoneNumber != null && member.phoneNumber!.isNotEmpty + ? member.phoneNumber! + : "Nessun telefono", + ), ], ), trailing: const Icon(Icons.edit_note), @@ -148,7 +153,7 @@ class _StaffScreenState extends State { void _openStaffForm(BuildContext context, {StaffMemberModel? member}) { final nameController = TextEditingController(text: member?.name); final emailController = TextEditingController(text: member?.email); - final phoneController = TextEditingController(text: member?.phone); + final phoneController = TextEditingController(text: member?.phoneNumber); // 1. Inizializziamo la lista temporanea attingendo dallo stato del Cubit // Usiamo storesByStaff (la mappa che indicizza i negozi per ogni ID dipendente) @@ -264,16 +269,16 @@ class _StaffScreenState extends State { child: ElevatedButton( onPressed: () { final companyId = context - .read() + .read() .state .company! - .id; - - final updatedMember = StaffMemberModel( + .id!; + //TODO sistemare StaffScreen per il nuovo modello + /* final updatedMember = StaffMemberModel( id: member?.id, name: nameController.text, email: emailController.text, - phone: phoneController.text, + phoneNumber: phoneController.text, companyId: companyId, ); @@ -281,7 +286,7 @@ class _StaffScreenState extends State { context.read().saveStaffWithStores( member: updatedMember, selectedStoreIds: tempSelectedStores, - ); + ); */ Navigator.pop(context); }, diff --git a/lib/features/master_data/store/bloc/store_cubit.dart b/lib/features/master_data/store/bloc/store_cubit.dart index 8d34f5d..09d31d0 100644 --- a/lib/features/master_data/store/bloc/store_cubit.dart +++ b/lib/features/master_data/store/bloc/store_cubit.dart @@ -1,6 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/features/master_data/providers/models/provider_model.dart'; import 'package:flux/features/master_data/staff/data/staff_repository.dart'; import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; @@ -13,7 +13,7 @@ part 'store_state.dart'; class StoreCubit extends Cubit { final StoreRepository _repository = GetIt.I(); final StaffRepository _staffRepository = GetIt.I(); - final SessionBloc _sessionBloc = GetIt.I(); + final SessionCubit _sessionCubit = GetIt.I(); StoreCubit() : super(const StoreState(stores: [])); @@ -33,7 +33,7 @@ class StoreCubit extends Cubit { emit(state.copyWith(status: StoreStatus.loading)); try { final stores = await _repository.fetchAllCompanyStores( - _sessionBloc.state.company!.id, + _sessionCubit.state.company!.id!, ); final Map> staffByStore = {}; for (StoreModel store in stores) { diff --git a/lib/features/master_data/store/models/store_model.dart b/lib/features/master_data/store/models/store_model.dart index 5f65f86..f30e0c1 100644 --- a/lib/features/master_data/store/models/store_model.dart +++ b/lib/features/master_data/store/models/store_model.dart @@ -81,6 +81,17 @@ class StoreModel extends Equatable { ); } + factory StoreModel.empty() { + return const StoreModel( + nome: '', + companyId: '', + indirizzo: '', + cap: '', + comune: '', + provincia: '', + ); + } + factory StoreModel.fromMap(Map map) { final providersPivotList = map['associated_providers'] as List?; List providers = []; diff --git a/lib/features/master_data/store/ui/create_store_screen.dart b/lib/features/master_data/store/ui/create_store_screen.dart index 1b3c9e8..7ea5786 100644 --- a/lib/features/master_data/store/ui/create_store_screen.dart +++ b/lib/features/master_data/store/ui/create_store_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/features/master_data/store/bloc/store_cubit.dart'; import 'package:flux/features/master_data/store/models/store_model.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/widgets/flux_text_field.dart'; @@ -34,7 +34,7 @@ class _CreateStoreScreenState extends State { /// Funzione magica per copiare i dati dall'azienda salvata in GetIt void _useCompanyAddress() { - final company = context.read().state.company; + final company = context.read().state.company; if (company != null) { setState(() { _indirizzoController.text = company.indirizzo; @@ -58,7 +58,7 @@ class _CreateStoreScreenState extends State { void _onSave() { if (_formKey.currentState!.validate()) { - final company = context.read().state.company; + final company = context.read().state.company; if (company == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -69,7 +69,7 @@ class _CreateStoreScreenState extends State { final store = StoreModel( nome: _nomeController.text.trim(), - companyId: company.id, + companyId: company.id!, indirizzo: _indirizzoController.text.trim(), cap: _capController.text.trim(), comune: _comuneController.text.trim(), @@ -84,10 +84,10 @@ class _CreateStoreScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Il tuo primo Negozio')), - body: BlocConsumer( - listener: (context, state) { + body: BlocBuilder( + /* listener: (context, state) { if (state.status == StoreStatus.success) { - context.read().add(AppStarted()); + context.read().; } if (state.status == StoreStatus.failure) { ScaffoldMessenger.of(context).showSnackBar( @@ -96,7 +96,7 @@ class _CreateStoreScreenState extends State { ), ); } - }, + }, */ builder: (context, state) { return SafeArea( child: SingleChildScrollView( diff --git a/lib/features/master_data/store/ui/store_form.dart b/lib/features/master_data/store/ui/store_form.dart index ee0b88b..1795169 100644 --- a/lib/features/master_data/store/ui/store_form.dart +++ b/lib/features/master_data/store/ui/store_form.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/features/master_data/store/bloc/store_cubit.dart'; import 'package:flux/features/master_data/store/models/store_model.dart'; @@ -130,10 +130,10 @@ class _StoreFormState extends State { comune: comuneController.text, provincia: provinciaController.text, companyId: context - .read() + .read() .state .company! - .id, // Recuperiamo la companyId + .id!, // Recuperiamo la companyId isActive: widget.store?.isActive ?? true, isPaid: widget.store?.isPaid ?? false, paymentExpiration: widget.store?.paymentExpiration, diff --git a/lib/features/onboarding/blocs/onboarding_cubit.dart b/lib/features/onboarding/blocs/onboarding_cubit.dart new file mode 100644 index 0000000..5c7186c --- /dev/null +++ b/lib/features/onboarding/blocs/onboarding_cubit.dart @@ -0,0 +1,105 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/core/data/core_repository.dart'; +import 'package:flux/features/company/models/company_model.dart'; +import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; +import 'package:flux/features/master_data/store/models/store_model.dart'; +import 'package:flux/features/onboarding/blocs/onboarding_state.dart'; +import 'package:get_it/get_it.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class OnboardingCubit extends Cubit { + final CoreRepository _repository; + final SessionCubit _sessionCubit; + + OnboardingCubit(this._sessionCubit, this._repository) + : super(OnboardingState( + step: _sessionCubit.state.onboardingStep, + companyId: _sessionCubit.state.company?.id, + storeId: _sessionCubit.state.currentStore?.id, + )); + + // --- STEP 1: REGISTRAZIONE AZIENDA --- + Future saveCompany(String companyName) async { + emit(state.copyWith(isLoading: true)); + final company = CompanyModel.empty().copyWith( + ragioneSociale: companyName, + userId: GetIt.I().auth.currentUser!.id, + subscriptionTier: SubscriptionTier.pro, + subscriptionStatus: SubscriptionStatus.trialing, + trialEndsAt: DateTime.now().add(const Duration(days: 14)), + ); + try { + // Il repository restituisce il modello creato con l'ID di Supabase + final savedCompany = await _repository.createCompany(company); + + emit( + state.copyWith( + isLoading: false, + step: OnboardingStep.store, + companyId: savedCompany.id, + ), + ); + } catch (e) { + emit( + state.copyWith( + isLoading: false, + error: "Errore salvataggio azienda: $e", + ), + ); + } + } + + // --- STEP 2: REGISTRAZIONE PRIMO NEGOZIO --- + Future saveStore(StoreModel store) async { + if (state.companyId == null) return; + if (state.companyId == '') return; + + emit(state.copyWith(isLoading: true)); + try { + // Iniettiamo forzatamente il companyId ottenuto dallo step precedente + final storeToSave = store.copyWith(companyId: state.companyId); + final savedStore = await _repository.createStore(storeToSave); + _sessionCubit.changeStore(savedStore); + + emit( + state.copyWith( + isLoading: false, + step: OnboardingStep.staff, + storeId: savedStore.id, + ), + ); + } catch (e) { + emit( + state.copyWith(isLoading: false, error: "Errore salvataggio store: $e"), + ); + } + } + + // --- STEP 3: REGISTRAZIONE PROFILO STAFF (PAZIENTE ZERO) --- + Future saveStaff(StaffMemberModel staff) async { + if (state.companyId == null) return; + if (state.companyId == '') return; + + emit(state.copyWith(isLoading: true)); + try { + // PARANOIA MODE: Forziamo i legami e il ruolo di sistema 'admin' + final staffToSave = staff.copyWith( + companyId: state.companyId!, + userId: _sessionCubit.state.user!.id, // Dall'utente loggato in Supabase + systemRole: SystemRole.admin, // Blindato! + ); + + await _repository.createStaffMember(staffToSave); + + emit(state.copyWith(isLoading: false, step: OnboardingStep.completed)); + + // Svegliamo il SessionCubit: lui ricalcolerà tutto e aprirà la Dashboard + await _sessionCubit.initializeSession(); + } catch (e) { + emit( + state.copyWith(isLoading: false, error: "Errore creazione profilo: $e"), + ); + } + } +} diff --git a/lib/features/onboarding/blocs/onboarding_state.dart b/lib/features/onboarding/blocs/onboarding_state.dart new file mode 100644 index 0000000..f28fa17 --- /dev/null +++ b/lib/features/onboarding/blocs/onboarding_state.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; + +class OnboardingState extends Equatable { + final OnboardingStep step; + final bool isLoading; + final String? error; + final String? companyId; // Salvato dopo lo Step 1 + final String? storeId; // Salvato dopo lo Step 2 + + const OnboardingState({ + required this.step, + this.isLoading = false, + this.error, + this.companyId, + this.storeId, + }); + + OnboardingState copyWith({ + OnboardingStep? step, + bool? isLoading, + String? error, + String? companyId, + String? storeId, + }) { + return OnboardingState( + step: step ?? this.step, + isLoading: isLoading ?? this.isLoading, + error: error, // Se non passato, resettiamo l'errore + companyId: companyId ?? this.companyId, + storeId: storeId ?? this.storeId, + ); + } + + @override + List get props => [step, isLoading, error, companyId, storeId]; +} diff --git a/lib/features/onboarding/ui/company_onboarding_form.dart b/lib/features/onboarding/ui/company_onboarding_form.dart new file mode 100644 index 0000000..6a06b08 --- /dev/null +++ b/lib/features/onboarding/ui/company_onboarding_form.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/utils/validators.dart'; +import 'package:flux/core/widgets/flux_text_field.dart'; +import 'package:flux/features/company/models/company_model.dart'; +import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart'; +import 'package:flux/features/onboarding/blocs/onboarding_state.dart'; + +class CompanyOnboardingForm extends StatefulWidget { + final OnboardingState state; + const CompanyOnboardingForm({super.key, required this.state}); + + @override + State createState() => _CompanyOnboardingFormState(); +} + +class _CompanyOnboardingFormState extends State { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + + @override + void dispose() { + _nameCtrl.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(32), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + "Iniziamo! 🏢", + style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + "Come si chiama la tua Azienda? \n(Potrai inserire i dati di fatturazione in seguito).", + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + const SizedBox(height: 48), + + FluxTextField( + label: 'Ragione Sociale / Nome Azienda', + controller: _nameCtrl, + validator: notEmptyValidator, + keyboardType: TextInputType.name, + textCapitalization: TextCapitalization.words, + autocorrect: false, + onSubmitted: (_) => _submit(), + ), + + const Spacer(), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => _submit(), + child: const Text( + "Salva e Prosegui", + style: TextStyle(fontSize: 16), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + void _submit() { + if (_formKey.currentState!.validate()) { + context.read().saveCompany(_nameCtrl.text.trim()); + } + } +} diff --git a/lib/features/onboarding/ui/onboarding_screen.dart b/lib/features/onboarding/ui/onboarding_screen.dart new file mode 100644 index 0000000..926c894 --- /dev/null +++ b/lib/features/onboarding/ui/onboarding_screen.dart @@ -0,0 +1,115 @@ +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/onboarding/blocs/onboarding_cubit.dart'; +import 'package:flux/features/onboarding/blocs/onboarding_state.dart'; +import 'package:flux/features/onboarding/ui/company_onboarding_form.dart'; +import 'package:flux/features/onboarding/ui/staff_onboarding_form.dart'; +import 'package:flux/features/onboarding/ui/store_onboarding_form.dart'; + +class OnboardingScreen extends StatefulWidget { + const OnboardingScreen({super.key}); + + @override + State createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends State { + late PageController _pageController; + + @override + void initState() { + super.initState(); + // Calcoliamo la pagina iniziale in base allo step salvato nel Cubit + final initialStep = context.read().state.step; + _pageController = PageController(initialPage: _getPageIndex(initialStep)); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + int _getPageIndex(OnboardingStep step) { + switch (step) { + case OnboardingStep.company: + return 0; + case OnboardingStep.store: + return 1; + case OnboardingStep.staff: + return 2; + default: + return 0; + } + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + // Ascoltiamo i cambi di stato per animare la pagina e mostrare errori + listenWhen: (previous, current) => + previous.step != current.step || previous.error != current.error, + listener: (context, state) { + // Gestione Errori + if (state.error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.error!), backgroundColor: Colors.red), + ); + } + + // Se ha finito, non animiamo nulla: il GoRouter prenderà il controllo a breve! + if (state.step == OnboardingStep.completed) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Configurazione completata! Benvenuto a bordo 🚀"), + backgroundColor: Colors.green, + ), + ); + return; + } + + // Animazione cambio pagina + if (state.step != OnboardingStep.completed) { + final targetPage = _getPageIndex(state.step); + if (_pageController.hasClients && + _pageController.page?.round() != targetPage) { + _pageController.animateToPage( + targetPage, + duration: const Duration(milliseconds: 600), + curve: Curves.easeInOutCubic, // Animazione super fluida + ); + } + } + }, + builder: (context, state) { + return Scaffold( + body: SafeArea( + child: Stack( + children: [ + // IL PAGEVIEW CORAZZATO + PageView( + controller: _pageController, + physics: + const NeverScrollableScrollPhysics(), // Vietato lo swipe manuale! + children: [ + CompanyOnboardingForm(state: state), + StoreOnboardingForm(state: state), + StaffOnboardingForm(), + ], + ), + + // OVERLAY CARICAMENTO + if (state.isLoading) + Container( + color: Colors.black.withValues(alpha: 0.4), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/onboarding/ui/staff_onboarding_form.dart b/lib/features/onboarding/ui/staff_onboarding_form.dart new file mode 100644 index 0000000..a280058 --- /dev/null +++ b/lib/features/onboarding/ui/staff_onboarding_form.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/utils/validators.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/onboarding/blocs/onboarding_cubit.dart'; + +class StaffOnboardingForm extends StatefulWidget { + const StaffOnboardingForm({super.key}); + + @override + State createState() => _StaffOnboardingFormState(); +} + +class _StaffOnboardingFormState extends State { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + final _jobTitleCtrl = TextEditingController(); + + @override + void dispose() { + _nameCtrl.dispose(); + _emailCtrl.dispose(); + _jobTitleCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(32), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + "Il tuo Profilo 👤", + style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + "Ultimo step! Crea il tuo profilo operativo per iniziare a usare FLUX.", + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + const SizedBox(height: 48), + FluxTextField( + label: 'Nome', + keyboardType: TextInputType.name, + controller: _nameCtrl, + validator: notEmptyValidator, + textCapitalization: TextCapitalization.words, + autocorrect: false, + ), + const SizedBox(height: 16), + FluxTextField( + label: 'Email', + keyboardType: TextInputType.emailAddress, + controller: _emailCtrl, + textCapitalization: TextCapitalization.none, + ), + const SizedBox(height: 16), + FluxTextField( + label: 'Etichetta Ruolo (es. Titolare, Manager)', + controller: _jobTitleCtrl, + keyboardType: TextInputType.text, + textCapitalization: TextCapitalization.words, + onSubmitted: (_) => _submit(), + ), + const Spacer(), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: Colors.black, // O il tuo context.accent + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => _submit(), + child: const Text( + "Entra in FLUX", + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + void _submit() { + if (_formKey.currentState!.validate()) { + final newStaff = StaffMemberModel.empty().copyWith( + name: _nameCtrl.text.trim(), + email: _emailCtrl.text.trim(), + jobTitle: _jobTitleCtrl.text.trim(), + ); + context.read().saveStaff(newStaff); + } + } +} diff --git a/lib/features/onboarding/ui/store_onboarding_form.dart b/lib/features/onboarding/ui/store_onboarding_form.dart new file mode 100644 index 0000000..0f26732 --- /dev/null +++ b/lib/features/onboarding/ui/store_onboarding_form.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; // <-- IMPORTANTE per i formatter +import 'package:flutter_bloc/flutter_bloc.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/onboarding/blocs/onboarding_cubit.dart'; +import 'package:flux/features/onboarding/blocs/onboarding_state.dart'; + +class StoreOnboardingForm extends StatefulWidget { + final OnboardingState state; + const StoreOnboardingForm({super.key, required this.state}); + + @override + State createState() => _StoreOnboardingFormState(); +} + +class _StoreOnboardingFormState extends State { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _addressCtrl = TextEditingController(); + final _cityCtrl = TextEditingController(); + final _zipCodeCtrl = TextEditingController(); + final _provinceCtrl = TextEditingController(); + + @override + void dispose() { + _nameCtrl.dispose(); + _addressCtrl.dispose(); + _cityCtrl.dispose(); + _zipCodeCtrl.dispose(); + _provinceCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(32.0), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + "Il tuo Negozio 🏪", + style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + "Dove si trova il tuo punto vendita principale? (Potrai aggiungerne altri in seguito).", + style: TextStyle(fontSize: 16), + ), + const SizedBox(height: 32), + + FluxTextField( + controller: _nameCtrl, + label: "Nome del Negozio", + keyboardType: TextInputType.name, + validator: (value) => + value == null || value.isEmpty ? "Obbligatorio" : null, + ), + const SizedBox(height: 16), + + FluxTextField( + controller: _addressCtrl, + keyboardType: TextInputType.streetAddress, + label: "Indirizzo", + validator: (value) => + value == null || value.isEmpty ? "Obbligatorio" : null, + ), + const SizedBox(height: 16), + + // IL LAYOUT RESPONSIVO PREMIUM + LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= 600; + + if (isDesktop) { + // --- DESKTOP: Tutti su una riga --- + return Row( + crossAxisAlignment: CrossAxisAlignment + .start, // Allinea in alto se ci sono errori + children: [ + Expanded(flex: 2, child: _buildZipField()), + const SizedBox(width: 16), + Expanded(flex: 5, child: _buildCityField()), + const SizedBox(width: 16), + Expanded(flex: 2, child: _buildProvField()), + ], + ); + } else { + // --- MOBILE: Comune sopra, CAP e Provincia sotto --- + return Column( + children: [ + _buildCityField(), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 3, child: _buildZipField()), + const SizedBox(width: 16), + Expanded(flex: 2, child: _buildProvField()), + ], + ), + ], + ); + } + }, + ), + + const Spacer(), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => _submit(), + child: const Text( + "Salva Negozio", + style: TextStyle(fontSize: 16), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + void _submit() { + if (_formKey.currentState!.validate()) { + // MIRACOLO DELLA FACTORY EMPTY! + final newStore = StoreModel.empty().copyWith( + nome: _nameCtrl.text.trim(), + indirizzo: _addressCtrl.text.trim(), + comune: _cityCtrl.text.trim(), + cap: _zipCodeCtrl.text.trim(), + // Formattiamo in maiuscolo qui, al momento del salvataggio! + provincia: _provinceCtrl.text.trim().toUpperCase(), + ); + context.read().saveStore(newStore); + } + } + + // --- WIDGET ESTRATTI PER PULIZIA --- + + Widget _buildCityField() { + return FluxTextField( + label: 'Comune', + controller: _cityCtrl, + keyboardType: TextInputType.name, + ); + } + + Widget _buildZipField() { + return FluxTextField( + label: 'CAP', + controller: _zipCodeCtrl, + keyboardType: TextInputType.number, + // Trucchetto Premium: Limita i caratteri ma NASCONDE il contatore UI + inputFormatters: [LengthLimitingTextInputFormatter(5)], + ); + } + + Widget _buildProvField() { + return FluxTextField( + label: 'Prov.', + controller: _provinceCtrl, + keyboardType: TextInputType.text, + // Rende la tastiera del telefono automaticamente maiuscola + textCapitalization: TextCapitalization.characters, + inputFormatters: [LengthLimitingTextInputFormatter(2)], + onSubmitted: (_) => _submit(), + ); + } +} diff --git a/lib/features/services/blocs/services_cubit.dart b/lib/features/services/blocs/services_cubit.dart index db154bd..3d62ca4 100644 --- a/lib/features/services/blocs/services_cubit.dart +++ b/lib/features/services/blocs/services_cubit.dart @@ -2,7 +2,7 @@ import 'package:equatable/equatable.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/features/services/data/services_repository.dart'; import 'package:flux/features/services/models/energy_service_model.dart'; @@ -16,7 +16,7 @@ part 'services_state.dart'; class ServicesCubit extends Cubit { final ServicesRepository _repository = GetIt.I(); - final SessionBloc _sessionBloc = GetIt.I(); + final SessionCubit _sessionCubit = GetIt.I(); ServicesCubit() : super(const ServicesState(status: ServicesStatus.initial)); @@ -41,7 +41,7 @@ class ServicesCubit extends Cubit { try { final currentOffset = refresh ? 0 : state.allServices.length; - final companyId = _sessionBloc.state.company?.id; + final companyId = _sessionCubit.state.company?.id; if (companyId == null) { throw Exception("Company ID non trovato nella sessione"); @@ -126,10 +126,10 @@ class ServicesCubit extends Cubit { emit( state.copyWith( currentService: ServiceModel( - storeId: _sessionBloc.state.selectedStore?.id ?? '', + storeId: _sessionCubit.state.currentStore?.id ?? '', number: '', // Sarà compilato dall'utente createdAt: DateTime.now(), - companyId: _sessionBloc.state.company!.id, + companyId: _sessionCubit.state.company!.id!, ), status: ServicesStatus.ready, ), diff --git a/lib/features/services/data/services_repository.dart b/lib/features/services/data/services_repository.dart index f81f417..a20715d 100644 --- a/lib/features/services/data/services_repository.dart +++ b/lib/features/services/data/services_repository.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:flux/features/services/models/service_file_model.dart'; @@ -9,7 +9,7 @@ import '../models/service_model.dart'; class ServicesRepository { final _supabase = Supabase.instance.client; - final companyId = GetIt.I.get().state.company!.id; + final companyId = GetIt.I.get().state.company!.id; final CustomerRepository _customerRepository = GetIt.I(); // --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO --- diff --git a/lib/features/services/ui/service_form_screen/entertainment_service_card.dart b/lib/features/services/ui/service_form_screen/entertainment_service_card.dart index 9243a96..4e07001 100644 --- a/lib/features/services/ui/service_form_screen/entertainment_service_card.dart +++ b/lib/features/services/ui/service_form_screen/entertainment_service_card.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart'; import 'package:flux/features/master_data/providers/models/provider_model.dart'; import 'package:flux/features/services/data/services_repository.dart'; @@ -281,7 +281,7 @@ class _EntertainmentFormState extends State<_EntertainmentForm> { // Suggerimenti rapidi (Chip) FutureBuilder>( future: GetIt.I().fetchTopEntertainmentTypes( - GetIt.I().state.company!.id, + GetIt.I().state.company!.id!, ), builder: (context, snapshot) { final suggestions = snapshot.data ?? ["Netflix", "DAZN", "Sky"]; diff --git a/lib/features/services/utils/service_actions.dart b/lib/features/services/utils/service_actions.dart index 4a51388..3159591 100644 --- a/lib/features/services/utils/service_actions.dart +++ b/lib/features/services/utils/service_actions.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/features/master_data/store/bloc/store_cubit.dart'; import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/services/models/service_model.dart'; @@ -8,8 +8,8 @@ import 'package:go_router/go_router.dart'; /// Avvia la creazione di un nuovo servizio partendo dalla selezione dell'operatore. void startNewService(BuildContext context) { - final session = context.read().state; - final currentStoreId = session.selectedStore?.id; + final session = context.read().state; + final currentStoreId = session.currentStore?.id; if (currentStoreId == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -59,7 +59,7 @@ void startNewService(BuildContext context) { employeeId: member.id, number: '', createdAt: DateTime.now(), - companyId: session.company!.id, + companyId: session.company!.id!, ), ); diff --git a/lib/main.dart b/lib/main.dart index 9b0ff8b..4698234 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/features/auth/bloc/auth_cubit.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/core/data/core_repository.dart'; import 'package:flux/core/routes/app_router.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/theme/bloc/theme_bloc.dart'; -import 'package:flux/features/auth/bloc/auth_bloc.dart'; -import 'package:flux/features/company/bloc/company_bloc.dart'; -import 'package:flux/features/company/data/company_repository.dart'; import 'package:flux/features/customers/blocs/customer_cubit.dart'; import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/features/master_data/products/blocs/product_cubit.dart'; @@ -21,33 +24,31 @@ import 'package:flux/features/master_data/store/data/store_repository.dart'; import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/services/data/services_repository.dart'; import 'package:flux/features/settings/settings.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await dotenv.load(fileName: ".env"); + + // Inizializza le dipendenze PRIMA di lanciare l'app await setupLocator(); runApp( MultiBlocProvider( providers: [ + BlocProvider(create: (context) => AuthCubit()), BlocProvider( create: (context) => ThemeBloc()..add(LoadThemeEvent()), ), - BlocProvider(create: (_) => GetIt.I()), - BlocProvider(create: (_) => AuthBloc()), - BlocProvider(create: (_) => CompanyBloc()), - BlocProvider(create: (_) => StoreCubit()..loadStores()), + // Il Vigile Urbano viene inizializzato! + BlocProvider(create: (_) => GetIt.I()), + + // Cubit delle feature + BlocProvider(create: (_) => StoreCubit()), BlocProvider(create: (_) => CustomerCubit()), BlocProvider(create: (_) => ProductCubit()), - BlocProvider(create: (_) => StaffCubit()..loadAllStaff()), + BlocProvider(create: (_) => StaffCubit()), BlocProvider(create: (_) => ServicesCubit()), - BlocProvider( - create: (_) => ProvidersCubit()..loadProviders(null), - ), + BlocProvider(create: (_) => ProvidersCubit()), ], child: const FluxApp(), ), @@ -56,6 +57,7 @@ void main() async { Future setupLocator() async { final GetIt getIt = GetIt.instance; + getIt.registerSingleton( await SharedPreferences.getInstance(), ); @@ -64,16 +66,32 @@ Future setupLocator() async { url: dotenv.env['SUPABASE_URL'] ?? '', anonKey: dotenv.env['SUPABASE_ANON_KEY'] ?? '', ); + //await Supabase.instance.client.auth.signOut(); getIt.registerSingleton(Supabase.instance.client); + + // Settings getIt.registerLazySingleton(() => AppSettings()); - getIt.registerLazySingleton(() => CompanyRepository()); + + // Repositories + getIt.registerLazySingleton( + () => CoreRepository(), + ); // <-- NUOVO getIt.registerLazySingleton(() => StoreRepository()); getIt.registerLazySingleton(() => CustomerRepository()); getIt.registerLazySingleton(() => ProductRepository()); getIt.registerLazySingleton(() => StaffRepository()); getIt.registerLazySingleton(() => ServicesRepository()); getIt.registerLazySingleton(() => ProviderRepository()); - getIt.registerSingleton(SessionBloc()..add(AppStarted())); + + // NOTA: CompanyRepository l'ho tolto perché la logica della Company + // ora è gestita dal CoreRepository durante l'Onboarding. + // Se ti serve per altro, rimettilo pure! + + // Inizializziamo il SessionCubit (che prende CoreRepository e SharedPreferences) + // Usiamo registerSingleton così viene creato subito e inizia ad ascoltare Supabase Auth. + getIt.registerSingleton( + SessionCubit(getIt(), getIt()), + ); } class FluxApp extends StatefulWidget { @@ -89,26 +107,27 @@ class _FluxAppState extends State { @override void initState() { super.initState(); - // Lo creiamo una volta sola all'avvio dell'app - _router = AppRouter.createRouter(context.read()); + // Creiamo il router passandogli il Cubit per i redirect + _router = AppRouter.createRouter(context.read()); } @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state.status == SessionStatus.unknown) { + return BlocBuilder( + builder: (context, sessionState) { + if (sessionState.status == SessionStatus.initial) { return _buildLoadingScreen(); } + return BlocBuilder( - builder: (context, state) { + builder: (context, themeState) { return MaterialApp.router( title: 'FLUX Gestionale', debugShowCheckedModeBanner: false, theme: fluxLightTheme, darkTheme: fluxDarkTheme, - themeMode: state.currentTheme.themeMode, - routerConfig: _router, // Usa l'istanza mantenuta nello stato + themeMode: themeState.currentTheme.themeMode, + routerConfig: _router, ); }, ); @@ -116,7 +135,6 @@ class _FluxAppState extends State { ); } - // Una semplice schermata di caricamento coerente con il brand Widget _buildLoadingScreen() { return MaterialApp( debugShowCheckedModeBanner: false, @@ -125,7 +143,6 @@ class _FluxAppState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - // Qui puoi mettere il tuo logo const Icon(Icons.bolt, size: 64, color: Colors.blue), const SizedBox(height: 24), const CircularProgressIndicator(), diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 5d07423..21d8f8b 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 4e9e7fb..a199ace 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -4,6 +4,8 @@ PODS: - file_picker (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) + - pdfx (1.0.0): + - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -14,6 +16,7 @@ DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - FlutterMacOS (from `Flutter/ephemeral`) + - pdfx (from `Flutter/ephemeral/.symlinks/plugins/pdfx/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -24,6 +27,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos FlutterMacOS: :path: Flutter/ephemeral + pdfx: + :path: Flutter/ephemeral/.symlinks/plugins/pdfx/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin url_launcher_macos: @@ -33,6 +38,7 @@ SPEC CHECKSUMS: app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + pdfx: 1e79f57f7a6ce2f4a4c30f21fa54d3dc82441b51 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd diff --git a/pubspec.lock b/pubspec.lock index 3c09862..556ab24 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: dart_jsonwebtoken - sha256: cb79ed79baa02b4f59a597bf365873cbd83f9bb15273d63f7803802d21717c7d + sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.4.1" dbus: dependency: transitive description: @@ -210,10 +210,10 @@ packages: dependency: "direct main" description: name: flutter_dotenv - sha256: d4130c4a43e0b13fefc593bc3961f2cb46e30cb79e253d4a526b1b5d24ae1ce4 + sha256: d41da11fb497314fbf89811ec30af02d1d898b47980a129f0a8c0a1720460ba2 url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.0.1" flutter_lints: dependency: "direct dev" description: @@ -276,10 +276,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "48fb2f42ad057476fa4b733cb95e9f9ea7b0b010bb349ea491dca7dbdb18ffc4" + sha256: "08b742eef4f71c9df5af543751cd0b7f1c679c4088488f4223ecaddc1a813b79" url: "https://pub.dev" source: hosted - version: "17.2.0" + version: "17.2.2" google_fonts: dependency: "direct main" description: @@ -292,10 +292,10 @@ packages: dependency: transitive description: name: gotrue - sha256: ecdf3fa3ef8c5f886390ba0056d00d29138c02c39984e9caa8194dffd8a73ef7 + sha256: "7a4172601553e61716f5c3dd243aa3297e13308e07eb85b7853c941ba585dcf5" url: "https://pub.dev" source: hosted - version: "2.19.0" + version: "2.20.0" gtk: dependency: transitive description: @@ -344,6 +344,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" jwt_decode: dependency: transitive description: @@ -448,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -476,10 +500,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.23" + version: "2.3.1" path_provider_foundation: dependency: transitive description: @@ -564,10 +588,10 @@ packages: dependency: transitive description: name: postgrest - sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460 + sha256: "9d61b3d4a88fcf9424d400127c54d49ed1b56ec30838fc0a33a64f31d4e694cc" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" provider: dependency: transitive description: @@ -588,10 +612,10 @@ packages: dependency: transitive description: name: realtime_client - sha256: ee8e71af7a834e960f5b2f494f398117488036fbdb11f422611f7287fbf40562 + sha256: "7dfccf372d2f55aacfeefb6186f65a06f3ffae383fe042dbeef9d85d33487576" url: "https://pub.dev" source: hosted - version: "2.7.1" + version: "2.7.3" retry: dependency: transitive description: @@ -689,10 +713,10 @@ packages: dependency: transitive description: name: storage_client - sha256: "085a08fd67f234d575113957c04a0e8d0a3050129762f939ce831ee2c0df8257" + sha256: "4801e8ca219a35e51cbb30589aba5306667ae8935b792504595a45273cef0b18" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.5.2" stream_channel: dependency: transitive description: @@ -713,18 +737,18 @@ packages: dependency: transitive description: name: supabase - sha256: "89b190b585f8609fe1537cbf53eae0c9fda9b777591b064d1150c6f26e607a84" + sha256: "40e5a8833c8834e140ef53b60a6181849667eba9ca125acb7f8e24c6a769d418" url: "https://pub.dev" source: hosted - version: "2.10.4" + version: "2.10.6" supabase_flutter: dependency: "direct main" description: name: supabase_flutter - sha256: c2974cfdfeb5de517652a35f3ef0d1f3159e068de82b50ccaa27908a2b45fb82 + sha256: c02ce58abcaf86cb8055ad40bfd98bbf5b93fed3b5b56b8220d88ed03842818b url: "https://pub.dev" source: hosted - version: "2.12.2" + version: "2.12.4" synchronized: dependency: transitive description: @@ -881,10 +905,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.1.0" web: dependency: transitive description: diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 24b817c..3a7bdc1 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)