This commit is contained in:
2026-04-06 10:55:56 +02:00
parent c6c61f1a31
commit 4930d25e58
15 changed files with 658 additions and 11 deletions

View File

@@ -0,0 +1,57 @@
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,
// Qui potresti passare il "Codice Negozio" nei data dell'utente
data: {'store_code': event.storeCode},
);
// 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",
),
);
}
});
}
}

View File

@@ -0,0 +1,24 @@
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;
final String? storeCode;
const LoginRequested({
required this.email,
required this.password,
this.storeCode,
});
@override
List<Object?> get props => [email, password, storeCode];
}

View File

@@ -0,0 +1,26 @@
part of 'auth_bloc.dart';
enum AuthStatus { initial, loading, success, failure }
class AuthState extends Equatable {
const AuthState({
required this.status,
this.error,
required this.isLoginMode,
});
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}) {
return AuthState(
status: status ?? this.status,
error: error,
isLoginMode: isLoginMode ?? this.isLoginMode,
);
}
}

View File

@@ -0,0 +1,31 @@
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 'company_events.dart';
part 'company_state.dart';
class CompanyBloc extends Bloc<CompanyEvent, CompanyState> {
final _supabase = GetIt.instance<SupabaseClient>();
CompanyBloc() : super(const CompanyState(status: CompanyStatus.initial)) {
on<SaveCompanyRequested>((event, emit) async {
emit(const CompanyState(status: CompanyStatus.loading));
try {
final userId = _supabase.auth.currentUser!.id;
await _supabase.from('companies').insert({
'owner_id': userId,
'ragione_sociale': event.ragioneSociale,
'partita_iva': event.partitaIva,
'codice_univoco': event.codiceUnivoco,
});
emit(const CompanyState(status: CompanyStatus.success));
} catch (e) {
emit(CompanyState(status: CompanyStatus.failure, error: e.toString()));
}
});
}
}

View File

@@ -0,0 +1,16 @@
part of 'company_bloc.dart';
abstract class CompanyEvent {
const CompanyEvent();
}
final class SaveCompanyRequested extends CompanyEvent {
final String ragioneSociale;
final String partitaIva;
final String codiceUnivoco;
const SaveCompanyRequested(
this.ragioneSociale,
this.partitaIva,
this.codiceUnivoco,
);
}

View File

@@ -0,0 +1,13 @@
part of 'company_bloc.dart';
enum CompanyStatus { initial, loading, success, failure }
class CompanyState extends Equatable {
final CompanyStatus status;
final String? error;
const CompanyState({required this.status, this.error});
@override
List<Object?> get props => [status, error];
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/data/enums.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>();
StreamSubscription<AuthState>? _authSubscription;
SessionBloc() : super(const SessionState.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.unauthenticated());
return;
}
// 1. Controlla se l'utente ha una Company
final company = await _supabase
.from('company')
.select()
.eq('user_id', event.userId!)
.maybeSingle();
if (company == null) {
emit(SessionState.authenticatedNoCompany(event.userId!));
return;
}
// 2. Controlla i negozi
final stores = await _supabase
.from('store')
.select()
.eq('company_id', company['id']);
if (stores.isEmpty) {
emit(SessionState.authenticatedNoStore(event.userId!, company['id']));
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!);
}
});
}
@override
Future<void> close() {
_authSubscription?.cancel();
return super.close();
}
}
class SharedPreferencesKeys {}

View File

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

View File

@@ -0,0 +1,37 @@
part of 'session_bloc.dart';
enum SessionStatus {
unknown,
unauthenticated,
authenticatedNoCompany, // Loggato ma deve creare l'azienda
authenticatedNoStore, // Ha l'azienda ma deve creare/scegliere il primo negozio
ready,
}
class SessionState extends Equatable {
final SessionStatus status;
final String? userId;
final String? companyId;
const SessionState._({
this.status = SessionStatus.unknown,
this.userId,
this.companyId,
});
const SessionState.unknown() : this._();
const SessionState.unauthenticated()
: this._(status: SessionStatus.unauthenticated);
const SessionState.authenticatedNoCompany(String userId)
: this._(status: SessionStatus.authenticatedNoCompany, userId: userId);
const SessionState.authenticatedNoStore(String userId, String companyId)
: this._(
status: SessionStatus.authenticatedNoStore,
userId: userId,
companyId: companyId,
);
const SessionState.ready(String userId)
: this._(status: SessionStatus.ready, userId: userId);
@override
List<Object?> get props => [status, userId];
}

View File

@@ -18,3 +18,11 @@ enum AppThemeMode {
); );
} }
} }
enum PrefKeys {
theme('themeModeSetting'),
lastStore('lastStore');
const PrefKeys(this.value);
final String value;
}

View File

@@ -1,11 +1,14 @@
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/blocs/session/session_bloc.dart';
import 'package:flux/theme/theme.dart'; import 'package:flux/theme/theme.dart';
import 'package:flux/theme/theme_bloc.dart'; import 'package:flux/theme/theme_bloc.dart';
import 'package:flux/ui/auth/auth_screen.dart';
import 'package:flux/ui/home_screen.dart'; import 'package:flux/ui/home_screen.dart';
import 'package:flux/ui/settings/settings.dart'; import 'package:flux/ui/settings/settings.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -14,9 +17,21 @@ void main() async {
await SharedPreferences.getInstance(), await SharedPreferences.getInstance(),
); );
getIt.registerSingleton<AppSettings>(AppSettings()); getIt.registerSingleton<AppSettings>(AppSettings());
await Supabase.initialize(
url: 'https://pvqpjloswwvtfoxbkfbh.supabase.co',
anonKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB2cXBqbG9zd3d2dGZveGJrZmJoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzQ5MjkyNjgsImV4cCI6MjA5MDUwNTI2OH0.-7nitlX1pzPGscGawlIF0vhwuD_w209FUU0PxDNGm0Y',
);
getIt.registerSingleton<SupabaseClient>(Supabase.instance.client);
runApp( runApp(
BlocProvider( MultiBlocProvider(
create: (context) => ThemeBloc()..add(LoadThemeEvent()), providers: [
BlocProvider(create: (context) => ThemeBloc()..add(LoadThemeEvent())),
BlocProvider<SessionBloc>(
create: (context) => SessionBloc()..add(AppStarted()),
),
],
child: const FluxApp(), child: const FluxApp(),
), ),
); );
@@ -35,9 +50,41 @@ class FluxApp extends StatelessWidget {
theme: fluxLightTheme, theme: fluxLightTheme,
darkTheme: fluxDarkTheme, darkTheme: fluxDarkTheme,
themeMode: state.currentTheme.themeMode, // Applica il tema FLUX themeMode: state.currentTheme.themeMode, // Applica il tema FLUX
home: const HomeScreen(), home: const AuthGuard(),
); );
}, },
); );
} }
} }
class AuthGuard extends StatelessWidget {
const AuthGuard({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<SessionBloc, SessionState>(
builder: (context, state) {
switch (state.status) {
case SessionStatus.unauthenticated:
return const AuthScreen();
case SessionStatus.authenticatedNoCompany:
// Pagina forzata per inserimento P.IVA e Ragione Sociale
return const CreateCompanyScreen();
case SessionStatus.authenticatedNoStore:
// Pagina forzata per creare il primo punto vendita
return const CreateStoreScreen();
case SessionStatus.ready:
return const HomeScreen(); // Entra direttamente nel negozio salvato
default:
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
},
);
}
}

View File

@@ -0,0 +1,111 @@
import 'package:equatable/equatable.dart';
class CompanyModel extends Equatable {
final String id;
final DateTime createdAt;
final String userId;
final String ragioneSociale;
final String indirizzo;
final String cap;
final String citta;
final String provincia;
final String partitaIva;
final String codiceFiscale;
final String codiceUnivoco;
final bool isPaid;
final DateTime? paymentExpiration;
const CompanyModel({
required this.id,
required this.createdAt,
required this.userId,
required this.ragioneSociale,
required this.indirizzo,
required this.cap,
required this.citta,
required this.provincia,
required this.partitaIva,
required this.codiceFiscale,
required this.codiceUnivoco,
required this.isPaid,
this.paymentExpiration,
});
// --- FROM JSON (Dall'input di Supabase a Dart) ---
factory CompanyModel.fromJson(Map<String, dynamic> json) {
return CompanyModel(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
userId: json['user_id'],
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,
);
}
// --- TO JSON (Da Dart a Supabase) ---
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(),
// 'id', 'created_at' e 'user_id' di solito sono gestiti dal DB in fase di insert
};
}
// --- COPY WITH (Per aggiornamenti parziali) ---
CompanyModel copyWith({
String? ragioneSociale,
String? indirizzo,
String? cap,
String? citta,
String? provincia,
String? partitaIva,
String? codiceFiscale,
String? codiceUnivoco,
bool? isPaid,
DateTime? paymentExpiration,
}) {
return CompanyModel(
id: id,
createdAt: createdAt,
userId: 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,
);
}
@override
List<Object?> get props => [
id,
userId,
ragioneSociale,
partitaIva,
isPaid,
paymentExpiration,
];
}

View File

@@ -243,12 +243,24 @@ ThemeData fluxLightTheme = ThemeData(
); );
extension FluxThemeContext on BuildContext { extension FluxThemeContext on BuildContext {
// Recupera il colore 'secondary' definito nel tuo ColorScheme (il Turchese Flux) // --- Colori del Brand ---
Color get accent => Theme.of(this).colorScheme.secondary; Color get primary => Theme.of(this).colorScheme.primary; // Blu Flux
Color get accent => Theme.of(this).colorScheme.secondary; // Turchese Flux
// Puoi aggiungere anche questi per comodità futura: // --- Superfici ---
Color get primary => Theme.of(this).colorScheme.primary;
Color get surface => Theme.of(this).colorScheme.surface; Color get surface => Theme.of(this).colorScheme.surface;
Color get background =>
Theme.of(this).colorScheme.surfaceContainerHighest; // O background
// --- Testi (La parte mancante) ---
// Mappiamo primaryText sul colore del titolo e secondaryText su quello del corpo
Color get primaryText => Color get primaryText =>
Theme.of(this).textTheme.titleLarge?.color ?? Colors.black; Theme.of(this).textTheme.titleLarge?.color ?? Colors.white;
Color get secondaryText =>
Theme.of(this).textTheme.bodyMedium?.color ?? Colors.grey;
// Opzionale: un colore ancora più tenue per suggerimenti o icone disabilitate
Color get hintText =>
Theme.of(this).textTheme.bodySmall?.color ??
Colors.grey.withValues(alpha: 0.5);
} }

View File

@@ -8,20 +8,19 @@ part 'theme_events.dart';
part 'theme_state.dart'; part 'theme_state.dart';
class ThemeBloc extends Bloc<ThemeEvent, ThemeState> { class ThemeBloc extends Bloc<ThemeEvent, ThemeState> {
static const String _savedThemeKey = "themeModeSetting";
final SharedPreferences _prefs = GetIt.I.get<SharedPreferences>(); final SharedPreferences _prefs = GetIt.I.get<SharedPreferences>();
ThemeBloc() : super(ThemeState(currentTheme: AppThemeMode.system)) { ThemeBloc() : super(ThemeState(currentTheme: AppThemeMode.system)) {
on<LoadThemeEvent>((event, emit) { on<LoadThemeEvent>((event, emit) {
emit( emit(
state.copyWith( state.copyWith(
currentTheme: AppThemeMode.fromValue( currentTheme: AppThemeMode.fromValue(
_prefs.getString(_savedThemeKey), _prefs.getString(PrefKeys.theme.value),
), ),
), ),
); );
}); });
on<ChangeThemeEvent>((event, emit) async { on<ChangeThemeEvent>((event, emit) async {
await _prefs.setString(_savedThemeKey, event.appThemeMode.value); await _prefs.setString(PrefKeys.theme.value, event.appThemeMode.value);
emit(state.copyWith(currentTheme: event.appThemeMode)); emit(state.copyWith(currentTheme: event.appThemeMode));
}); });
} }

View File

@@ -0,0 +1,176 @@
// lib/ui/auth/auth_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/blocs/auth/auth_bloc.dart';
import 'package:flux/theme/theme.dart';
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _storeController = TextEditingController();
@override
Widget build(BuildContext context) {
return BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.failure) {
// Mostra l'errore che arriva da Supabase (es. "Invalid login credentials")
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!), backgroundColor: Colors.red),
);
}
},
builder: (context, state) {
return Column(
children: [
_AuthTextField(
label: 'Email',
icon: Icons.email,
controller: _emailController,
),
_AuthTextField(
label: 'Password',
icon: Icons.lock,
isPassword: true,
controller: _passwordController,
),
if (!state.isLoginMode)
_AuthTextField(
label: 'Codice Negozio',
icon: Icons.store,
controller: _storeController,
),
ElevatedButton(
onPressed: state.status == AuthStatus.loading
? null
: () {
context.read<AuthBloc>().add(
LoginRequested(
email: _emailController.text.trim(),
password: _passwordController.text.trim(),
storeCode: _storeController.text.trim(),
),
);
},
child: state.status == AuthStatus.loading
? const CircularProgressIndicator()
: Text(state.isLoginMode ? 'ACCEDI' : 'REGISTRATI'),
),
],
);
},
);
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_storeController.dispose();
super.dispose();
}
}
class _FluxLogo extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Icon(
Icons.all_inclusive,
size: 80,
color: context.accent,
), // Simbolo Flux/Infinito
Text(
'FLUX',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 8,
),
),
],
);
}
}
class _AuthTextField extends StatefulWidget {
final String label;
final IconData icon;
final bool isPassword;
final TextEditingController? controller; // Aggiunto per recuperare i dati
const _AuthTextField({
required this.label,
required this.icon,
this.isPassword = false,
this.controller,
});
@override
State<_AuthTextField> createState() => _AuthTextFieldState();
}
class _AuthTextFieldState extends State<_AuthTextField> {
bool _obscureText = true; // Stato interno per la visibilità
@override
Widget build(BuildContext context) {
return TextField(
controller: widget.controller,
obscureText: widget.isPassword ? _obscureText : false,
style: TextStyle(color: context.primaryText),
decoration: InputDecoration(
prefixIcon: Icon(
widget.icon,
color: context.accent.withValues(alpha: 0.6),
),
labelText: widget.label,
labelStyle: TextStyle(color: context.secondaryText, fontSize: 14),
filled: true,
fillColor: context.surface.withValues(alpha: 0.5),
// --- LOGICA OCCHIO PASSWORD ---
suffixIcon: widget.isPassword
? IconButton(
icon: Icon(
_obscureText
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: context.secondaryText,
size: 20,
),
onPressed: () {
setState(() {
_obscureText = !_obscureText;
});
},
)
: null,
// --- BORDI STILE FLUX ---
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: context.secondaryText.withValues(alpha: 0.1),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: context.accent, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
);
}
}