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