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