This commit is contained in:
2026-04-21 19:16:41 +02:00
parent 497e8eb867
commit 2b0980799f
12 changed files with 247 additions and 101 deletions

7
GEMINI.md Normal file
View File

@@ -0,0 +1,7 @@
## General Instructions
- Prefer the Flutter clean architecture
- Prefer Cubit over Bloc and Stateful where is good practice
- Data Models must always extend Equatable, have copyWith, empty and fromMap, toMap when they'll be saved in db
- Use GoRouter
- Use Enums when possible instead of hardcoded Strings or Numbers for better readability and less error prone

View File

@@ -20,6 +20,7 @@ class SessionCubit extends Cubit<SessionState> {
SessionCubit(this._repository, this._prefs) SessionCubit(this._repository, this._prefs)
: super(const SessionState(status: SessionStatus.initial)) { : super(const SessionState(status: SessionStatus.initial)) {
initializeSession();
// Possiamo metterci in ascolto dei cambiamenti di Auth (Login/Logout) // Possiamo metterci in ascolto dei cambiamenti di Auth (Login/Logout)
_supabase.auth.onAuthStateChange.listen((data) { _supabase.auth.onAuthStateChange.listen((data) {
final AuthChangeEvent event = data.event; final AuthChangeEvent event = data.event;

View File

@@ -1,10 +0,0 @@
part of 'session_cubit.dart';
abstract class SessionEvent {}
class AppStarted extends SessionEvent {}
class UserChanged extends SessionEvent {
final String? userId;
UserChanged(this.userId);
}

View File

@@ -14,10 +14,7 @@ class CoreRepository {
final response = await _supabase final response = await _supabase
.from('company') .from('company')
.select() .select()
.eq( .eq('user_id', userId) // <-- Assicurati di avere questo campo nel DB!
'owner_id',
userId,
) // <-- Assicurati di avere questo campo nel DB!
.maybeSingle(); .maybeSingle();
if (response == null) return null; if (response == null) return null;
@@ -34,7 +31,7 @@ class CoreRepository {
.select() .select()
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('is_active', true) // Buona pratica .eq('is_active', true) // Buona pratica
.order('name'); // O come si chiama il campo nome .order('nome'); // O come si chiama il campo nome
return (response as List).map((s) => StoreModel.fromMap(s)).toList(); return (response as List).map((s) => StoreModel.fromMap(s)).toList();
} catch (e) { } catch (e) {

View File

@@ -1,18 +1,22 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.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/auth/ui/auth_screen.dart';
import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/customer_detail_screen.dart'; import 'package:flux/features/customers/ui/customer_detail_screen.dart';
import 'package:flux/features/home/ui/home_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/products/ui/products_screen.dart';
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
import 'package:flux/features/onboarding/ui/onboarding_screen.dart'; import 'package:flux/features/onboarding/ui/onboarding_screen.dart';
import 'package:flux/features/services/models/service_model.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: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 'package:go_router/go_router.dart';
// Importa il tuo SessionCubit e lo State
import 'package:flux/core/blocs/session/session_cubit.dart';
class AppRouter { class AppRouter {
static GoRouter createRouter(SessionCubit sessionCubit) { static GoRouter createRouter(SessionCubit sessionCubit) {
return GoRouter( return GoRouter(
@@ -66,7 +70,13 @@ class AppRouter {
), ),
GoRoute( GoRoute(
path: '/onboarding', path: '/onboarding',
builder: (context, state) => const OnboardingScreen(), 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 // Nota: All'interno di questa schermata useremo il PageView pilotato
// dall'OnboardingStep. Al router non interessa quale step è attivo, // dall'OnboardingStep. Al router non interessa quale step è attivo,
// gli basta sapere che deve stare rinchiuso qui dentro! // gli basta sapere che deve stare rinchiuso qui dentro!

View File

@@ -4,6 +4,7 @@ import 'package:flux/core/theme/theme.dart';
class FluxTextField extends StatefulWidget { class FluxTextField extends StatefulWidget {
final String label; final String label;
final String? labelText;
final IconData? icon; final IconData? icon;
final bool isPassword; final bool isPassword;
final bool autoFocus; final bool autoFocus;
@@ -19,6 +20,7 @@ class FluxTextField extends StatefulWidget {
const FluxTextField({ const FluxTextField({
super.key, // Usiamo super.key per Flutter moderno super.key, // Usiamo super.key per Flutter moderno
required this.label, required this.label,
this.labelText,
this.icon, this.icon,
this.isPassword = false, this.isPassword = false,
this.autoFocus = false, this.autoFocus = false,
@@ -60,11 +62,11 @@ class _FluxTextFieldState extends State<FluxTextField> {
maxLines: widget.minLines != null ? null : widget.maxLines, maxLines: widget.minLines != null ? null : widget.maxLines,
style: TextStyle(color: context.primaryText), style: TextStyle(color: context.primaryText),
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: Icon( prefixIcon: widget.icon != null
widget.icon, ? Icon(widget.icon, color: context.accent.withValues(alpha: 0.6))
color: context.accent.withValues(alpha: 0.6), : null,
),
labelText: widget.label, labelText: widget.labelText ?? widget.label,
labelStyle: TextStyle(color: context.secondaryText, fontSize: 14), labelStyle: TextStyle(color: context.secondaryText, fontSize: 14),
filled: true, filled: true,
fillColor: context.surface.withValues(alpha: 0.5), fillColor: context.surface.withValues(alpha: 0.5),

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
part 'auth_state.dart'; part 'auth_state.dart';
@@ -34,14 +35,20 @@ class AuthCubit extends Cubit<AuthState> {
password: password, password: password,
); );
// Se la sessione è null, significa che Supabase ha inviato l'email di conferma
if (res.session == null) { if (res.session == null) {
// Caso: Conferma Email attivata su Supabase
emit( emit(
state.copyWith( state.copyWith(
status: AuthStatus.initial, status: AuthStatus.initial,
infoMessage: "Controlla la tua email per confermare l'account!", 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. // Se non è null, ha fatto il login automatico. Stessa cosa di sopra, ci pensa il SessionCubit.
} }

View File

@@ -5,6 +5,8 @@ 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/staff/models/staff_member_model.dart';
import 'package:flux/features/master_data/store/models/store_model.dart'; import 'package:flux/features/master_data/store/models/store_model.dart';
import 'package:flux/features/onboarding/blocs/onboarding_state.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> { class OnboardingCubit extends Cubit<OnboardingState> {
final CoreRepository _repository; final CoreRepository _repository;
@@ -14,8 +16,15 @@ class OnboardingCubit extends Cubit<OnboardingState> {
: super(OnboardingState(step: _sessionCubit.state.onboardingStep)); : super(OnboardingState(step: _sessionCubit.state.onboardingStep));
// --- STEP 1: REGISTRAZIONE AZIENDA --- // --- STEP 1: REGISTRAZIONE AZIENDA ---
Future<void> saveCompany(CompanyModel company) async { Future<void> saveCompany(String companyName) async {
emit(state.copyWith(isLoading: true)); 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 { try {
// Il repository restituisce il modello creato con l'ID di Supabase // Il repository restituisce il modello creato con l'ID di Supabase
final savedCompany = await _repository.createCompany(company); final savedCompany = await _repository.createCompany(company);

View File

@@ -41,7 +41,7 @@ class _CompanyOnboardingFormState extends State<CompanyOnboardingForm> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
"Inserisci i dati della tua attività per configurare il tuo ambiente FLUX.", "Come si chiama la tua Azienda? \n(Potrai inserire i dati di fatturazione in seguito).",
style: TextStyle(fontSize: 16, color: Colors.grey), style: TextStyle(fontSize: 16, color: Colors.grey),
), ),
const SizedBox(height: 48), const SizedBox(height: 48),
@@ -51,7 +51,6 @@ class _CompanyOnboardingFormState extends State<CompanyOnboardingForm> {
controller: _nameCtrl, controller: _nameCtrl,
validator: notEmptyValidator, validator: notEmptyValidator,
), ),
const SizedBox(height: 16),
const Spacer(), const Spacer(),
ElevatedButton( ElevatedButton(
@@ -63,10 +62,9 @@ class _CompanyOnboardingFormState extends State<CompanyOnboardingForm> {
), ),
onPressed: () { onPressed: () {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
final newCompany = CompanyModel.empty().copyWith( context.read<OnboardingCubit>().saveCompany(
ragioneSociale: _nameCtrl.text.trim(), _nameCtrl.text.trim(),
); );
context.read<OnboardingCubit>().saveCompany(newCompany);
} }
}, },
child: const Text( child: const Text(

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/data/core_repository.dart';
import 'package:flux/core/utils/validators.dart'; import 'package:flux/core/utils/validators.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/store/models/store_model.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart'; import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
@@ -11,6 +11,8 @@ import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/onboarding/blocs/onboarding_state.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/company_onboarding_form.dart';
import 'package:flux/features/onboarding/ui/store_onboarding_form.dart';
import 'package:get_it/get_it.dart';
class OnboardingScreen extends StatefulWidget { class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key}); const OnboardingScreen({super.key});
@@ -24,13 +26,8 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
// --- CHIAVI DEI FORM (Per la validazione indipendente di ogni step) --- // --- CHIAVI DEI FORM (Per la validazione indipendente di ogni step) ---
final _storeFormKey = GlobalKey<FormState>();
final _staffFormKey = GlobalKey<FormState>(); final _staffFormKey = GlobalKey<FormState>();
// --- CONTROLLERS: STEP 2 (Store) ---
final _storeNameCtrl = TextEditingController();
final _storeAddressCtrl = TextEditingController();
// --- CONTROLLERS: STEP 3 (Staff) --- // --- CONTROLLERS: STEP 3 (Staff) ---
final _staffFirstNameCtrl = TextEditingController(); final _staffFirstNameCtrl = TextEditingController();
final _staffLastNameCtrl = TextEditingController(); final _staffLastNameCtrl = TextEditingController();
@@ -47,8 +44,6 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
@override @override
void dispose() { void dispose() {
_pageController.dispose(); _pageController.dispose();
_storeNameCtrl.dispose();
_storeAddressCtrl.dispose();
_staffFirstNameCtrl.dispose(); _staffFirstNameCtrl.dispose();
_staffLastNameCtrl.dispose(); _staffLastNameCtrl.dispose();
_staffJobTitleCtrl.dispose(); _staffJobTitleCtrl.dispose();
@@ -118,7 +113,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
const NeverScrollableScrollPhysics(), // Vietato lo swipe manuale! const NeverScrollableScrollPhysics(), // Vietato lo swipe manuale!
children: [ children: [
CompanyOnboardingForm(state: state), // Step 1: Company CompanyOnboardingForm(state: state), // Step 1: Company
_buildStoreForm(context, state), StoreOnboardingForm(state: state),
_buildStaffForm(context, state), _buildStaffForm(context, state),
], ],
), ),
@@ -137,67 +132,6 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
); );
} }
Widget _buildStoreForm(BuildContext context, OnboardingState state) {
return Padding(
padding: const EdgeInsets.all(32.0),
child: Form(
key: _storeFormKey,
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, color: Colors.grey),
),
const SizedBox(height: 48),
FluxTextField(
label: 'Nome Negozio (es. Sede Centrale)',
controller: _storeNameCtrl,
validator: notEmptyValidator,
),
const SizedBox(height: 16),
FluxTextField(
label: 'Indirizzo completo',
controller: _storeAddressCtrl,
validator: notEmptyValidator,
),
const Spacer(),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () {
if (_storeFormKey.currentState!.validate()) {
final newStore = StoreModel.empty().copyWith(
nome: _storeNameCtrl.text.trim(),
indirizzo: _storeAddressCtrl.text.trim(),
);
context.read<OnboardingCubit>().saveStore(newStore);
}
},
child: const Text(
"Salva Negozio",
style: TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
],
),
),
);
}
Widget _buildStaffForm(BuildContext context, OnboardingState state) { Widget _buildStaffForm(BuildContext context, OnboardingState state) {
return Padding( return Padding(
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),

View File

@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
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: 16),
FluxTextField(
controller: _nameCtrl,
label: "Nome del Negozio",
validator: (value) {
if (value == null || value.isEmpty) {
return "Il nome del negozio è obbligatorio";
}
return null;
},
),
const SizedBox(height: 16),
FluxTextField(
controller: _addressCtrl,
label: "Indirizzo",
validator: (value) {
if (value == null || value.isEmpty) {
return "L'indirizzo è obbligatorio";
}
return null;
},
),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 600) {
return Column(
children: [
SizedBox(
width: constraints.maxWidth,
child: FluxTextField(
label: 'Comune',
controller: _cityCtrl,
keyboardType: TextInputType.name,
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
SizedBox(
width: constraints.maxWidth * 0.4,
child: FluxTextField(
label: 'CAP',
controller: _zipCodeCtrl,
maxLength: 5,
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 16),
SizedBox(
width: constraints.maxWidth * 0.2,
child: FluxTextField(
label: 'Prov.',
controller: _provinceCtrl,
keyboardType: TextInputType.text,
onChanged: (value) =>
_provinceCtrl.text = value.toUpperCase(),
maxLength: 2,
),
),
],
),
],
);
} else {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 160,
child: FluxTextField(
label: 'CAP',
controller: _zipCodeCtrl,
maxLength: 5,
keyboardType: TextInputType.number,
),
),
SizedBox(
width: constraints.maxWidth * 0.5,
child: FluxTextField(
label: 'Comune',
controller: _cityCtrl,
keyboardType: TextInputType.name,
),
),
SizedBox(
width: 120,
child: FluxTextField(
label: 'Prov.',
controller: _provinceCtrl,
keyboardType: TextInputType.text,
onChanged: (value) =>
_provinceCtrl.text = value.toUpperCase(),
maxLength: 2,
),
),
],
);
}
},
),
const Spacer(),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () {
if (_formKey.currentState!.validate()) {
final newStore = StoreModel.empty().copyWith(
nome: _nameCtrl.text.trim(),
indirizzo: _addressCtrl.text.trim(),
comune: _cityCtrl.text.trim(),
cap: _zipCodeCtrl.text.trim(),
provincia: _provinceCtrl.text.trim(),
);
context.read<OnboardingCubit>().saveStore(newStore);
}
},
child: const Text(
"Salva Negozio",
style: TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
],
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flux/features/auth/bloc/auth_cubit.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -34,6 +35,7 @@ void main() async {
runApp( runApp(
MultiBlocProvider( MultiBlocProvider(
providers: [ providers: [
BlocProvider<AuthCubit>(create: (context) => AuthCubit()),
BlocProvider<ThemeBloc>( BlocProvider<ThemeBloc>(
create: (context) => ThemeBloc()..add(LoadThemeEvent()), create: (context) => ThemeBloc()..add(LoadThemeEvent()),
), ),