rework-onboarding (#7)

Onboarding completato, ora super rapido e top

Reviewed-on: http://catelliub.zapto.org:3000/brontomark/flux/pulls/7
Co-authored-by: Mark M2 Macbook <marco@catelli.it>
Co-committed-by: Mark M2 Macbook <marco@catelli.it>
This commit is contained in:
2026-04-22 11:06:02 +02:00
committed by brontomark
parent c5b5b76bd6
commit 90bd5ecacf
47 changed files with 1742 additions and 516 deletions

View File

@@ -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<SessionEvent, SessionState> {
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
final StoreRepository _storeRepository = GetIt.I.get<StoreRepository>();
StreamSubscription<AuthState>? _authSubscription;
SessionBloc() : super(const SessionState(status: SessionStatus.unknown)) {
on<AppStarted>((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<UserChanged>((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<SharedPreferences>();
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<void> close() {
_authSubscription?.cancel();
return super.close();
}
}

View File

@@ -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<SessionState> {
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<void> 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<void> changeStore(StoreModel newStore) async {
if (newStore.id != null) {
await _prefs.setString(_lastStoreKey, newStore.id!);
emit(state.copyWith(currentStore: newStore));
}
}
// --- LOGOUT ---
Future<void> signOut() async {
await _supabase.auth.signOut();
// Non serve emettere stato qui, ci pensa il listener nel costruttore!
}
}

View File

@@ -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);
}

View File

@@ -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<StoreModel> 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<Object?> 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<StoreModel>? 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;
}

View File

@@ -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<CompanyModel?> 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<List<StoreModel>> 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<StaffMemberModel?> 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<CompanyModel> 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<StoreModel> 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<StaffMemberModel> 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<SessionCubit>().state.currentStore!.id,
});
return StaffMemberModel.fromMap(response);
} catch (e) {
debugPrint('Creazione profilo staff fallita: $e');
throw Exception('Creazione profilo staff fallita: $e');
}
}
}

View File

@@ -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<SessionCubit>(),
GetIt.I.get<CoreRepository>(),
),
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<dynamic> 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<dynamic> stream) {
notifyListeners();
_subscription = stream.asBroadcastStream().listen((_) => notifyListeners());
_subscription = stream.asBroadcastStream().listen(
(dynamic _) => notifyListeners(),
);
}
late final StreamSubscription<dynamic> _subscription;

View File

@@ -0,0 +1,6 @@
String? notEmptyValidator(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Campo obbligatorio';
}
return null;
}

View File

@@ -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<TextInputFormatter>? 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<FluxTextField> {
@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<FluxTextField> {
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<FluxTextField> {
horizontal: 16,
vertical: 16,
),
suffixIcon: widget.isPassword
? IconButton(
icon: Icon(
@@ -95,9 +109,12 @@ class _FluxTextFieldState extends State<FluxTextField> {
)
: 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,
);
}
}

View File

@@ -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<AuthEvent, AuthState> {
final _supabase = GetIt.instance<SupabaseClient>();
AuthBloc()
: super(const AuthState(status: AuthStatus.initial, isLoginMode: true)) {
on<ToggleAuthMode>(
(event, emit) => emit(state.copyWith(isLoginMode: !state.isLoginMode)),
);
on<LoginRequested>((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<LogoutRequested>((event, emit) async {
await _supabase.auth.signOut();
});
}
}

View File

@@ -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<AuthState> {
final _supabase = GetIt.instance<SupabaseClient>();
AuthCubit() : super(const AuthState());
void toggleMode() {
emit(state.copyWith(isLoginMode: !state.isLoginMode));
}
Future<void> 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<SessionCubit>().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<void> requestLogout() async {
await _supabase.auth.signOut();
emit(state.copyWith(status: AuthStatus.initial));
}
}

View File

@@ -1,21 +0,0 @@
part of 'auth_bloc.dart';
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> 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<Object?> get props => [email, password];
}
class LogoutRequested extends AuthEvent {} // Logout

View File

@@ -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<Object?> 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<Object?> get props => [status, isLoginMode, errorMessage, infoMessage];
}

View File

@@ -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<AuthScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _isPassword = true;
@override
void dispose() {
@@ -24,19 +23,43 @@ class _AuthScreenState extends State<AuthScreen> {
super.dispose();
}
void _submit() {
// Chiudiamo la tastiera per fare pulizia a schermo
FocusScope.of(context).unfocus();
context.read<AuthCubit>().submitAuth(
_emailController.text.trim(),
_passwordController.text.trim(),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocConsumer<AuthBloc, AuthState>(
body: BlocConsumer<AuthCubit, AuthState>(
// 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<AuthScreen> {
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<AuthScreen> {
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<AuthScreen> {
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<AuthScreen> {
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<AuthScreen> {
TextButton(
onPressed: isLoading
? null
: () {
context.read<AuthBloc>().add(ToggleAuthMode());
},
: () => context.read<AuthCubit>().toggleMode(),
child: RichText(
text: TextSpan(
text: state.isLoginMode
@@ -144,13 +171,4 @@ class _AuthScreenState extends State<AuthScreen> {
),
);
}
void _submit() {
context.read<AuthBloc>().add(
LoginRequested(
email: _emailController.text.trim(),
password: _passwordController.text.trim(),
),
);
}
}

View File

@@ -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;
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<Object?> get props => [id, userId, ragioneSociale, partitaIva, isPaid];
List<Object?> 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;
}
}

View File

@@ -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<CreateCompanyScreen> {
void _onSave() {
if (_formKey.currentState!.validate()) {
// Recuperiamo l'ID utente attuale da Supabase o dal SessionBloc
final userId = context.read<SessionBloc>().state.userId!;
final userId = context.read<SessionCubit>().state.user!.id;
final company = CompanyModel(
userId: userId,
@@ -77,7 +77,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
onPressed: () {
// Qui chiami il tuo Bloc dell'autenticazione per fare logout
// Esempio se hai un AuthBloc o SessionBloc:
context.read<AuthBloc>().add(LogoutRequested());
//context.read<AuthBloc>().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<CreateCompanyScreen> {
//GetIt.I.get<AppSettings>().setCurrentCompany(state.company);
// 2. Notifichiamo il SessionBloc per cambiare pagina
context.read<SessionBloc>().add(AppStarted());
//context.read<SessionCubit>().add(AppStarted());
}
if (state.status == CompanyStatus.failure) {

View File

@@ -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<CustomerState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
// Variabile per gestire il debounce della ricerca
Timer? _searchDebounce;
@@ -22,7 +22,7 @@ class CustomerCubit extends Cubit<CustomerState> {
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<CustomerState> {
// 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<CustomerState> {
nome: name,
telefono: phone ?? '',
email: email ?? '',
companyId: _sessionBloc.state.company!.id,
companyId: _sessionCubit.state.company!.id!,
note: '',
);

View File

@@ -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<SupabaseClient>();
final String companyId = GetIt.I.get<SessionBloc>().state.company!.id;
final String companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
// Crea un nuovo cliente
Future<CustomerModel> saveCustomer(CustomerModel customer) async {

View File

@@ -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<CustomersContent> {
}
void _loadInitialCustomers() {
final companyId = context.read<SessionBloc>().state.company?.id;
final companyId = context.read<SessionCubit>().state.company?.id;
if (companyId != null) {
context.read<CustomerCubit>().loadCustomers();
}
}
void _onSearch(String query) {
final companyId = context.read<SessionBloc>().state.company?.id;
final companyId = context.read<SessionCubit>().state.company?.id;
if (companyId != null) {
context.read<CustomerCubit>().searchCustomers(query);
}
@@ -48,7 +48,7 @@ class _CustomersContentState extends State<CustomersContent> {
child: CustomerForm(
customer: customer,
onSave: (customerFromForm) {
final session = context.read<SessionBloc>().state;
final session = context.read<SessionCubit>().state;
final companyId = session.company?.id;
if (companyId == null) return;

View File

@@ -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<SessionBloc, SessionState>(
return BlocBuilder<SessionCubit, SessionState>(
builder: (context, state) {
final store = state.selectedStore;
final store = state.currentStore;
final company = state.company;
return Scaffold(

View File

@@ -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<HomeScreen> {
@override
Widget build(BuildContext context) {
return BlocBuilder<SessionBloc, SessionState>(
return BlocBuilder<SessionCubit, SessionState>(
builder: (context, state) {
return LayoutBuilder(
builder: (context, constraints) {
@@ -203,7 +203,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
const SizedBox(width: 12),
Text(
GetIt.I.get<SessionBloc>().state.company?.ragioneSociale ??
GetIt.I.get<SessionCubit>().state.company?.ragioneSociale ??
"Utente",
style: TextStyle(
fontWeight: FontWeight.bold,
@@ -246,9 +246,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
onPressed: () {
Navigator.pop(dialogContext); // Chiude la Dialog
context.read<AuthBloc>().add(
LogoutRequested(),
); // Esegue il logout
context.read<AuthCubit>().requestLogout(); // Esegue il logout
},
child: const Text("Esci"),
),

View File

@@ -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<ProductState> {
final ProductRepository _repository = GetIt.I<ProductRepository>();
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
ProductCubit() : super(const ProductState());
@@ -20,7 +20,7 @@ class ProductCubit extends Cubit<ProductState> {
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<ProductState> {
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<ProductState> {
// 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

View File

@@ -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<ProvidersState> {
final ProviderRepository _repository = GetIt.I<ProviderRepository>();
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
ProvidersCubit() : super(const ProvidersState());
@@ -61,7 +61,7 @@ class ProvidersCubit extends Cubit<ProvidersState> {
emit(state.copyWith(isLoading: true));
try {
final all = await _repository.fetchAllCompanyProviders(
_sessionBloc.state.company!.id,
_sessionCubit.state.company!.id!,
);
List<String> associated = [];
@@ -135,7 +135,7 @@ class ProvidersCubit extends Cubit<ProvidersState> {
) 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

View File

@@ -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<StaffState> {
final StaffRepository _repository = GetIt.I.get<StaffRepository>();
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
StaffCubit() : super(const StaffState());
@@ -19,7 +19,7 @@ class StaffCubit extends Cubit<StaffState> {
emit(state.copyWith(isLoading: true, error: null));
try {
final staff = await _repository.getStaffMembers(
_sessionBloc.state.company!.id,
_sessionCubit.state.company!.id!,
);
final Map<String, List<StoreModel>> storesByStaff = {};
for (StaffMemberModel member in staff) {

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<Object?> get props => [id, name, email, phone, isActive, companyId];
List<Object?> get props => [
id,
companyId,
userId,
name,
email,
phoneNumber,
jobTitle,
systemRole,
isActive,
];
}

View File

@@ -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<StaffScreen> {
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<StaffScreen> {
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<StaffScreen> {
child: ElevatedButton(
onPressed: () {
final companyId = context
.read<SessionBloc>()
.read<SessionCubit>()
.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<StaffScreen> {
context.read<StaffCubit>().saveStaffWithStores(
member: updatedMember,
selectedStoreIds: tempSelectedStores,
);
); */
Navigator.pop(context);
},

View File

@@ -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<StoreState> {
final StoreRepository _repository = GetIt.I<StoreRepository>();
final StaffRepository _staffRepository = GetIt.I<StaffRepository>();
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
StoreCubit() : super(const StoreState(stores: []));
@@ -33,7 +33,7 @@ class StoreCubit extends Cubit<StoreState> {
emit(state.copyWith(status: StoreStatus.loading));
try {
final stores = await _repository.fetchAllCompanyStores(
_sessionBloc.state.company!.id,
_sessionCubit.state.company!.id!,
);
final Map<String, List<StaffMemberModel>> staffByStore = {};
for (StoreModel store in stores) {

View File

@@ -81,6 +81,17 @@ class StoreModel extends Equatable {
);
}
factory StoreModel.empty() {
return const StoreModel(
nome: '',
companyId: '',
indirizzo: '',
cap: '',
comune: '',
provincia: '',
);
}
factory StoreModel.fromMap(Map<String, dynamic> map) {
final providersPivotList = map['associated_providers'] as List?;
List<ProviderModel> providers = [];

View File

@@ -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<CreateStoreScreen> {
/// Funzione magica per copiare i dati dall'azienda salvata in GetIt
void _useCompanyAddress() {
final company = context.read<SessionBloc>().state.company;
final company = context.read<SessionCubit>().state.company;
if (company != null) {
setState(() {
_indirizzoController.text = company.indirizzo;
@@ -58,7 +58,7 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
void _onSave() {
if (_formKey.currentState!.validate()) {
final company = context.read<SessionBloc>().state.company;
final company = context.read<SessionCubit>().state.company;
if (company == null) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -69,7 +69,7 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
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<CreateStoreScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Il tuo primo Negozio')),
body: BlocConsumer<StoreCubit, StoreState>(
listener: (context, state) {
body: BlocBuilder<StoreCubit, StoreState>(
/* listener: (context, state) {
if (state.status == StoreStatus.success) {
context.read<SessionBloc>().add(AppStarted());
context.read<SessionCubit>().;
}
if (state.status == StoreStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -96,7 +96,7 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
),
);
}
},
}, */
builder: (context, state) {
return SafeArea(
child: SingleChildScrollView(

View File

@@ -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<StoreForm> {
comune: comuneController.text,
provincia: provinciaController.text,
companyId: context
.read<SessionBloc>()
.read<SessionCubit>()
.state
.company!
.id, // Recuperiamo la companyId
.id!, // Recuperiamo la companyId
isActive: widget.store?.isActive ?? true,
isPaid: widget.store?.isPaid ?? false,
paymentExpiration: widget.store?.paymentExpiration,

View File

@@ -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<OnboardingState> {
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<void> saveCompany(String companyName) async {
emit(state.copyWith(isLoading: true));
final company = CompanyModel.empty().copyWith(
ragioneSociale: companyName,
userId: GetIt.I<SupabaseClient>().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<void> 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<void> 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"),
);
}
}
}

View File

@@ -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<Object?> get props => [step, isLoading, error, companyId, storeId];
}

View File

@@ -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<CompanyOnboardingForm> createState() => _CompanyOnboardingFormState();
}
class _CompanyOnboardingFormState extends State<CompanyOnboardingForm> {
final _formKey = GlobalKey<FormState>();
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<OnboardingCubit>().saveCompany(_nameCtrl.text.trim());
}
}
}

View File

@@ -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<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
late PageController _pageController;
@override
void initState() {
super.initState();
// Calcoliamo la pagina iniziale in base allo step salvato nel Cubit
final initialStep = context.read<OnboardingCubit>().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<OnboardingCubit, OnboardingState>(
// 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()),
),
],
),
),
);
},
);
}
}

View File

@@ -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<StaffOnboardingForm> createState() => _StaffOnboardingFormState();
}
class _StaffOnboardingFormState extends State<StaffOnboardingForm> {
final _formKey = GlobalKey<FormState>();
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<OnboardingCubit>().saveStaff(newStaff);
}
}
}

View File

@@ -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<StoreOnboardingForm> createState() => _StoreOnboardingFormState();
}
class _StoreOnboardingFormState extends State<StoreOnboardingForm> {
final _formKey = GlobalKey<FormState>();
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<OnboardingCubit>().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(),
);
}
}

View File

@@ -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<ServicesState> {
final ServicesRepository _repository = GetIt.I<ServicesRepository>();
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
ServicesCubit() : super(const ServicesState(status: ServicesStatus.initial));
@@ -41,7 +41,7 @@ class ServicesCubit extends Cubit<ServicesState> {
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<ServicesState> {
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,
),

View File

@@ -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<SessionBloc>().state.company!.id;
final companyId = GetIt.I.get<SessionCubit>().state.company!.id;
final CustomerRepository _customerRepository = GetIt.I<CustomerRepository>();
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---

View File

@@ -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<List<String>>(
future: GetIt.I<ServicesRepository>().fetchTopEntertainmentTypes(
GetIt.I<SessionBloc>().state.company!.id,
GetIt.I<SessionCubit>().state.company!.id!,
),
builder: (context, snapshot) {
final suggestions = snapshot.data ?? ["Netflix", "DAZN", "Sky"];

View File

@@ -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<SessionBloc>().state;
final currentStoreId = session.selectedStore?.id;
final session = context.read<SessionCubit>().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!,
),
);

View File

@@ -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<AuthCubit>(create: (context) => AuthCubit()),
BlocProvider<ThemeBloc>(
create: (context) => ThemeBloc()..add(LoadThemeEvent()),
),
BlocProvider<SessionBloc>(create: (_) => GetIt.I<SessionBloc>()),
BlocProvider<AuthBloc>(create: (_) => AuthBloc()),
BlocProvider<CompanyBloc>(create: (_) => CompanyBloc()),
BlocProvider<StoreCubit>(create: (_) => StoreCubit()..loadStores()),
// Il Vigile Urbano viene inizializzato!
BlocProvider<SessionCubit>(create: (_) => GetIt.I<SessionCubit>()),
// Cubit delle feature
BlocProvider<StoreCubit>(create: (_) => StoreCubit()),
BlocProvider<CustomerCubit>(create: (_) => CustomerCubit()),
BlocProvider<ProductCubit>(create: (_) => ProductCubit()),
BlocProvider<StaffCubit>(create: (_) => StaffCubit()..loadAllStaff()),
BlocProvider<StaffCubit>(create: (_) => StaffCubit()),
BlocProvider<ServicesCubit>(create: (_) => ServicesCubit()),
BlocProvider<ProvidersCubit>(
create: (_) => ProvidersCubit()..loadProviders(null),
),
BlocProvider<ProvidersCubit>(create: (_) => ProvidersCubit()),
],
child: const FluxApp(),
),
@@ -56,6 +57,7 @@ void main() async {
Future<void> setupLocator() async {
final GetIt getIt = GetIt.instance;
getIt.registerSingleton<SharedPreferences>(
await SharedPreferences.getInstance(),
);
@@ -64,16 +66,32 @@ Future<void> setupLocator() async {
url: dotenv.env['SUPABASE_URL'] ?? '',
anonKey: dotenv.env['SUPABASE_ANON_KEY'] ?? '',
);
//await Supabase.instance.client.auth.signOut();
getIt.registerSingleton<SupabaseClient>(Supabase.instance.client);
// Settings
getIt.registerLazySingleton<AppSettings>(() => AppSettings());
getIt.registerLazySingleton<CompanyRepository>(() => CompanyRepository());
// Repositories
getIt.registerLazySingleton<CoreRepository>(
() => CoreRepository(),
); // <-- NUOVO
getIt.registerLazySingleton<StoreRepository>(() => StoreRepository());
getIt.registerLazySingleton<CustomerRepository>(() => CustomerRepository());
getIt.registerLazySingleton<ProductRepository>(() => ProductRepository());
getIt.registerLazySingleton<StaffRepository>(() => StaffRepository());
getIt.registerLazySingleton<ServicesRepository>(() => ServicesRepository());
getIt.registerLazySingleton<ProviderRepository>(() => ProviderRepository());
getIt.registerSingleton<SessionBloc>(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>(
SessionCubit(getIt<CoreRepository>(), getIt<SharedPreferences>()),
);
}
class FluxApp extends StatefulWidget {
@@ -89,26 +107,27 @@ class _FluxAppState extends State<FluxApp> {
@override
void initState() {
super.initState();
// Lo creiamo una volta sola all'avvio dell'app
_router = AppRouter.createRouter(context.read<SessionBloc>());
// Creiamo il router passandogli il Cubit per i redirect
_router = AppRouter.createRouter(context.read<SessionCubit>());
}
@override
Widget build(BuildContext context) {
return BlocBuilder<SessionBloc, SessionState>(
builder: (context, state) {
if (state.status == SessionStatus.unknown) {
return BlocBuilder<SessionCubit, SessionState>(
builder: (context, sessionState) {
if (sessionState.status == SessionStatus.initial) {
return _buildLoadingScreen();
}
return BlocBuilder<ThemeBloc, ThemeState>(
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<FluxApp> {
);
}
// Una semplice schermata di caricamento coerente con il brand
Widget _buildLoadingScreen() {
return MaterialApp(
debugShowCheckedModeBanner: false,
@@ -125,7 +143,6 @@ class _FluxAppState extends State<FluxApp> {
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(),