diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..cd99714 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,7 @@ +## General Instructions + +- Prefer the Flutter clean architecture +- Prefer Cubit over Bloc and Stateful where is good practice +- Data Models must always extend Equatable, have copyWith, empty and fromMap, toMap when they'll be saved in db +- Use GoRouter +- Use Enums when possible instead of hardcoded Strings or Numbers for better readability and less error prone diff --git a/lib/core/blocs/session/session_cubit.dart b/lib/core/blocs/session/session_cubit.dart index d3d7d8f..6b8b592 100644 --- a/lib/core/blocs/session/session_cubit.dart +++ b/lib/core/blocs/session/session_cubit.dart @@ -20,6 +20,7 @@ class SessionCubit extends Cubit { 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; diff --git a/lib/core/blocs/session/session_events.dart b/lib/core/blocs/session/session_events.dart deleted file mode 100644 index f2cd441..0000000 --- a/lib/core/blocs/session/session_events.dart +++ /dev/null @@ -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); -} diff --git a/lib/core/data/core_repository.dart b/lib/core/data/core_repository.dart index 470f856..3eed0b6 100644 --- a/lib/core/data/core_repository.dart +++ b/lib/core/data/core_repository.dart @@ -14,10 +14,7 @@ class CoreRepository { final response = await _supabase .from('company') .select() - .eq( - 'owner_id', - userId, - ) // <-- Assicurati di avere questo campo nel DB! + .eq('user_id', userId) // <-- Assicurati di avere questo campo nel DB! .maybeSingle(); if (response == null) return null; @@ -34,7 +31,7 @@ class CoreRepository { .select() .eq('company_id', companyId) .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(); } catch (e) { diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index dd59f98..624a36c 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -1,18 +1,22 @@ import 'dart:async'; + 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/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/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'; -// Importa il tuo SessionCubit e lo State -import 'package:flux/core/blocs/session/session_cubit.dart'; - class AppRouter { static GoRouter createRouter(SessionCubit sessionCubit) { return GoRouter( @@ -66,7 +70,13 @@ class AppRouter { ), GoRoute( path: '/onboarding', - builder: (context, state) => const OnboardingScreen(), + builder: (context, state) => BlocProvider( + create: (context) => OnboardingCubit( + GetIt.I.get(), + GetIt.I.get(), + ), + child: const OnboardingScreen(), + ), // Nota: All'interno di questa schermata useremo il PageView pilotato // dall'OnboardingStep. Al router non interessa quale step è attivo, // gli basta sapere che deve stare rinchiuso qui dentro! diff --git a/lib/core/widgets/flux_text_field.dart b/lib/core/widgets/flux_text_field.dart index da7055b..f8ddc5e 100644 --- a/lib/core/widgets/flux_text_field.dart +++ b/lib/core/widgets/flux_text_field.dart @@ -4,6 +4,7 @@ import 'package:flux/core/theme/theme.dart'; class FluxTextField extends StatefulWidget { final String label; + final String? labelText; final IconData? icon; final bool isPassword; final bool autoFocus; @@ -19,6 +20,7 @@ class FluxTextField extends StatefulWidget { const FluxTextField({ super.key, // Usiamo super.key per Flutter moderno required this.label, + this.labelText, this.icon, this.isPassword = false, this.autoFocus = false, @@ -60,11 +62,11 @@ class _FluxTextFieldState extends State { maxLines: widget.minLines != null ? null : widget.maxLines, style: TextStyle(color: context.primaryText), decoration: InputDecoration( - prefixIcon: Icon( - widget.icon, - color: context.accent.withValues(alpha: 0.6), - ), - labelText: widget.label, + prefixIcon: widget.icon != null + ? Icon(widget.icon, color: context.accent.withValues(alpha: 0.6)) + : null, + + labelText: widget.labelText ?? widget.label, labelStyle: TextStyle(color: context.secondaryText, fontSize: 14), filled: true, fillColor: context.surface.withValues(alpha: 0.5), diff --git a/lib/features/auth/bloc/auth_cubit.dart b/lib/features/auth/bloc/auth_cubit.dart index 734cb22..95e9b56 100644 --- a/lib/features/auth/bloc/auth_cubit.dart +++ b/lib/features/auth/bloc/auth_cubit.dart @@ -1,5 +1,6 @@ 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'; @@ -34,14 +35,20 @@ class AuthCubit extends Cubit { password: password, ); - // Se la sessione è null, significa che Supabase ha inviato l'email di conferma if (res.session == null) { + // Caso: Conferma Email attivata su Supabase emit( state.copyWith( status: AuthStatus.initial, infoMessage: "Controlla la tua email per confermare l'account!", ), ); + } else { + // Caso: Autologin post-registrazione (Conferma email disattivata) + // 1. Fermiamo il frullino! + emit(state.copyWith(status: AuthStatus.initial)); + // 2. Svegliamo il SessionCubit! + GetIt.I().initializeSession(); } // Se non è null, ha fatto il login automatico. Stessa cosa di sopra, ci pensa il SessionCubit. } diff --git a/lib/features/onboarding/blocs/onboarding_cubit.dart b/lib/features/onboarding/blocs/onboarding_cubit.dart index ef430f1..d23f876 100644 --- a/lib/features/onboarding/blocs/onboarding_cubit.dart +++ b/lib/features/onboarding/blocs/onboarding_cubit.dart @@ -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/store/models/store_model.dart'; import 'package:flux/features/onboarding/blocs/onboarding_state.dart'; +import 'package:get_it/get_it.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; class OnboardingCubit extends Cubit { final CoreRepository _repository; @@ -14,8 +16,15 @@ class OnboardingCubit extends Cubit { : super(OnboardingState(step: _sessionCubit.state.onboardingStep)); // --- STEP 1: REGISTRAZIONE AZIENDA --- - Future saveCompany(CompanyModel company) async { + Future saveCompany(String companyName) async { emit(state.copyWith(isLoading: true)); + final company = CompanyModel.empty().copyWith( + ragioneSociale: companyName, + userId: GetIt.I().auth.currentUser!.id, + subscriptionTier: SubscriptionTier.pro, + subscriptionStatus: SubscriptionStatus.trialing, + trialEndsAt: DateTime.now().add(const Duration(days: 14)), + ); try { // Il repository restituisce il modello creato con l'ID di Supabase final savedCompany = await _repository.createCompany(company); diff --git a/lib/features/onboarding/ui/company_onboarding_form.dart b/lib/features/onboarding/ui/company_onboarding_form.dart index 1ec1484..9225da6 100644 --- a/lib/features/onboarding/ui/company_onboarding_form.dart +++ b/lib/features/onboarding/ui/company_onboarding_form.dart @@ -41,7 +41,7 @@ class _CompanyOnboardingFormState extends State { ), const SizedBox(height: 8), 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), ), const SizedBox(height: 48), @@ -51,7 +51,6 @@ class _CompanyOnboardingFormState extends State { controller: _nameCtrl, validator: notEmptyValidator, ), - const SizedBox(height: 16), const Spacer(), ElevatedButton( @@ -63,10 +62,9 @@ class _CompanyOnboardingFormState extends State { ), onPressed: () { if (_formKey.currentState!.validate()) { - final newCompany = CompanyModel.empty().copyWith( - ragioneSociale: _nameCtrl.text.trim(), + context.read().saveCompany( + _nameCtrl.text.trim(), ); - context.read().saveCompany(newCompany); } }, child: const Text( diff --git a/lib/features/onboarding/ui/onboarding_screen.dart b/lib/features/onboarding/ui/onboarding_screen.dart index 76822a2..46a8906 100644 --- a/lib/features/onboarding/ui/onboarding_screen.dart +++ b/lib/features/onboarding/ui/onboarding_screen.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/core/data/core_repository.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/staff/models/staff_member_model.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/features/onboarding/blocs/onboarding_state.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 { const OnboardingScreen({super.key}); @@ -24,13 +26,8 @@ class _OnboardingScreenState extends State { // --- CHIAVI DEI FORM (Per la validazione indipendente di ogni step) --- - final _storeFormKey = GlobalKey(); final _staffFormKey = GlobalKey(); - // --- CONTROLLERS: STEP 2 (Store) --- - final _storeNameCtrl = TextEditingController(); - final _storeAddressCtrl = TextEditingController(); - // --- CONTROLLERS: STEP 3 (Staff) --- final _staffFirstNameCtrl = TextEditingController(); final _staffLastNameCtrl = TextEditingController(); @@ -47,8 +44,6 @@ class _OnboardingScreenState extends State { @override void dispose() { _pageController.dispose(); - _storeNameCtrl.dispose(); - _storeAddressCtrl.dispose(); _staffFirstNameCtrl.dispose(); _staffLastNameCtrl.dispose(); _staffJobTitleCtrl.dispose(); @@ -118,7 +113,7 @@ class _OnboardingScreenState extends State { const NeverScrollableScrollPhysics(), // Vietato lo swipe manuale! children: [ CompanyOnboardingForm(state: state), // Step 1: Company - _buildStoreForm(context, state), + StoreOnboardingForm(state: state), _buildStaffForm(context, state), ], ), @@ -137,67 +132,6 @@ class _OnboardingScreenState extends State { ); } - 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().saveStore(newStore); - } - }, - child: const Text( - "Salva Negozio", - style: TextStyle(fontSize: 16), - ), - ), - const SizedBox(height: 16), - ], - ), - ), - ); - } - Widget _buildStaffForm(BuildContext context, OnboardingState state) { return Padding( padding: const EdgeInsets.all(32.0), diff --git a/lib/features/onboarding/ui/store_onboarding_form.dart b/lib/features/onboarding/ui/store_onboarding_form.dart new file mode 100644 index 0000000..d8fd4d2 --- /dev/null +++ b/lib/features/onboarding/ui/store_onboarding_form.dart @@ -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 createState() => _StoreOnboardingFormState(); +} + +class _StoreOnboardingFormState extends State { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _addressCtrl = TextEditingController(); + final _cityCtrl = TextEditingController(); + final _zipCodeCtrl = TextEditingController(); + final _provinceCtrl = TextEditingController(); + + @override + void dispose() { + _nameCtrl.dispose(); + _addressCtrl.dispose(); + _cityCtrl.dispose(); + _zipCodeCtrl.dispose(); + _provinceCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(32.0), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + "Il tuo Negozio 🏪", + style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + "Dove si trova il tuo punto vendita principale? (Potrai aggiungerne altri in seguito).", + style: TextStyle(fontSize: 16), + ), + const SizedBox(height: 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().saveStore(newStore); + } + }, + child: const Text( + "Salva Negozio", + style: TextStyle(fontSize: 16), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 5c21913..7d00799 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -34,6 +35,7 @@ void main() async { runApp( MultiBlocProvider( providers: [ + BlocProvider(create: (context) => AuthCubit()), BlocProvider( create: (context) => ThemeBloc()..add(LoadThemeEvent()), ),