8 Commits

Author SHA1 Message Date
2afe97c6db spostato aggiornamento tabella supabase sul worker del mac anche per FluxInstaller.exe
All checks were successful
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 3m20s
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m44s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m2s
2026-05-25 16:45:51 +02:00
4101b736e6 fix windows deployment
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m12s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m3s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 3m52s
2026-05-25 16:34:23 +02:00
b67354610d prova x sistemare pipeline windows
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m31s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m3s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 3m32s
2026-05-25 15:55:53 +02:00
b19c91a7dd refinements
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m56s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m9s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 5m9s
2026-05-25 14:29:48 +02:00
9b5d19b926 refinements 2026-05-25 12:49:04 +02:00
aad9a991c2 v
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m36s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m8s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 4m22s
2026-05-24 13:35:59 +02:00
7f0d18eed1 aggiorna link aggiornamenti
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m47s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m8s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-05-24 12:51:16 +02:00
879c848d77 v
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m52s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m13s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-05-24 12:42:11 +02:00
22 changed files with 970 additions and 583 deletions

View File

@@ -63,6 +63,15 @@ jobs:
files: "build/app/outputs/flutter-apk/app-release.apk"
api_key: ${{ secrets.MYRELEASE_TOKEN }}
- name: Aggiorna Link Android su Supabase
run: |
curl -X PATCH "https://pvqpjloswwvtfoxbkfbh.supabase.co/rest/v1/app_config?id=eq.49f18b19-2129-46c0-b690-a97db725b5a8" -H "apikey: ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Content-Type: application/json" -d "{\"download_url\": \"https://gitea.catelli.it/brontomark/flux/releases/download/${{ github.ref_name }}/app-release.apk\"}"
- name: Aggiorna Link Windows su Supabase
run: |
curl -X PATCH "https://pvqpjloswwvtfoxbkfbh.supabase.co/rest/v1/app_config?id=eq.1f888b30-5cbf-4a16-820c-5036a3af0cf8" -H "apikey: ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Content-Type: application/json" -d "{\"download_url\": \"https://gitea.catelli.it/brontomark/flux/releases/download/${{ github.ref_name }}/FluxInstaller.exe\"}"
# -----------------------------------------------------------------
# JOB 3: WEB & CLOUDFLARE DEPLOY (Gira sul tuo MacBook)
# -----------------------------------------------------------------

View File

@@ -29,6 +29,7 @@ import 'package:flux/features/master_data/providers/ui/provider_form_screen.dart
import 'package:flux/features/master_data/providers/ui/provider_list_screen.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
import 'package:flux/features/master_data/store/ui/stores_screen.dart';
import 'package:flux/features/notes/models/note_model.dart';
import 'package:flux/features/notes/ui/notes_form_screen.dart';
@@ -171,7 +172,11 @@ class AppRouter {
path:
'stores', // Sistemata l'inversione path/name -> /master-data/stores
name: Routes.stores,
builder: (context, state) => const StoresScreen(),
builder: (context, state) {
context.read<ProviderListCubit>().loadAllProviders();
context.read<StoreCubit>().loadStores();
return const StoresScreen();
},
),
GoRoute(
path: 'company-settings', // -> /master-data/company-settings

View File

@@ -21,6 +21,7 @@ class FluxTextField extends StatefulWidget {
final TextCapitalization? textCapitalization;
final bool? autocorrect;
final bool? enabled;
final Iterable<String>? autofillHints;
const FluxTextField({
super.key, // Usiamo super.key per Flutter moderno
@@ -41,6 +42,7 @@ class FluxTextField extends StatefulWidget {
this.textCapitalization,
this.autocorrect,
this.enabled = true,
this.autofillHints,
});
@override
@@ -118,6 +120,7 @@ class _FluxTextFieldState extends State<FluxTextField> {
textCapitalization: widget.textCapitalization ?? TextCapitalization.none,
enabled: widget.enabled,
autofillHints: widget.autofillHints,
);
}
}

View File

@@ -16,7 +16,8 @@ class AuthCubit extends Cubit<AuthState> {
emit(state.copyWith(isLoginMode: !state.isLoginMode));
}
Future<void> submitAuth(String email, String password) async {
Future<bool> submitAuth(String email, String password) async {
// <-- Modificato in bool
// Partiamo puliti: via vecchi messaggi ed errori
emit(state.copyWith(status: AuthStatus.loading));
@@ -27,9 +28,17 @@ class AuthCubit extends Cubit<AuthState> {
email: email,
password: password,
);
// NESSUN EMIT DI SUCCESS!
// Supabase lancerà l'evento 'signedIn', il SessionCubit lo catturerà
// e il GoRouter ci cambierà pagina. Noi stiamo a guardare il caricamento.
// Il login è andato a buon fine!
emit(
AuthState(
status: AuthStatus.initial,
isLoginMode: true,
errorMessage: null,
infoMessage: null,
),
);
return true;
} else {
// --- LOGICA SIGNUP ---
final AuthResponse res = await _supabase.auth.signUp(
@@ -38,7 +47,6 @@ class AuthCubit extends Cubit<AuthState> {
);
if (res.session == null) {
// Caso: Conferma Email attivata su Supabase
emit(
state.copyWith(
status: AuthStatus.initial,
@@ -48,16 +56,24 @@ class AuthCubit extends Cubit<AuthState> {
),
);
} 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.
// Anche la registrazione è andata a buon fine!
emit(
AuthState(
status: AuthStatus.initial,
isLoginMode: true,
errorMessage: null,
infoMessage: null,
),
);
return true;
}
} on AuthException catch (e) {
emit(state.copyWith(status: AuthStatus.failure, errorMessage: e.message));
return false; // <-- Il login è fallito
} catch (e) {
emit(
state.copyWith(
@@ -65,6 +81,7 @@ class AuthCubit extends Cubit<AuthState> {
errorMessage: "Errore imprevisto: $e",
),
);
return false; // <-- Il login è fallito
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
@@ -24,14 +25,18 @@ class _AuthScreenState extends State<AuthScreen> {
super.dispose();
}
void _submit() {
void _submit() async {
// Chiudiamo la tastiera per fare pulizia a schermo
FocusScope.of(context).unfocus();
context.read<AuthCubit>().submitAuth(
final isSuccess = await context.read<AuthCubit>().submitAuth(
_emailController.text.trim(),
_passwordController.text.trim(),
);
if (isSuccess) {
TextInput.finishAutofillContext();
}
}
@override
@@ -69,125 +74,133 @@ class _AuthScreenState extends State<AuthScreen> {
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// --- LOGO FLUX ---
const FluxLogoAuto(height: 80),
const SizedBox(height: 60),
child: AutofillGroup(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// --- LOGO FLUX ---
const FluxLogoAuto(height: 80),
const SizedBox(height: 60),
// --- TITOLO DINAMICO ---
Text(
state.isLoginMode
? context.l10n.authScreenWelcomeBack
: context.l10n.authScreenCreateAccount,
style: TextStyle(
color: context.primaryText,
fontSize: 24,
fontWeight: FontWeight.w900,
letterSpacing: 1.5,
// --- TITOLO DINAMICO ---
Text(
state.isLoginMode
? context.l10n.authScreenWelcomeBack
: context.l10n.authScreenCreateAccount,
style: TextStyle(
color: context.primaryText,
fontSize: 24,
fontWeight: FontWeight.w900,
letterSpacing: 1.5,
),
),
),
const SizedBox(height: 8),
Text(
state.isLoginMode
? context.l10n.authScreenLoginToManageYourBusiness
: context
.l10n
.authScreenStartTodayToDigitalizeYourStore,
textAlign: TextAlign.center,
style: TextStyle(color: context.secondaryText),
),
const SizedBox(height: 40),
const SizedBox(height: 8),
Text(
state.isLoginMode
? context.l10n.authScreenLoginToManageYourBusiness
: context
.l10n
.authScreenStartTodayToDigitalizeYourStore,
textAlign: TextAlign.center,
style: TextStyle(color: context.secondaryText),
),
const SizedBox(height: 40),
// --- CAMPI INPUT ---
FluxTextField(
label: context.l10n.authScreenBusinessEmail,
icon: Icons.email_outlined,
controller: _emailController,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 20),
FluxTextField(
label: 'Password',
icon: Icons.lock_outline,
isPassword: true, // Magia del FluxTextField!
controller: _passwordController,
onSubmitted: (_) =>
_submit(), // Se lo supporti nel tuo widget custom
),
// --- CAMPI INPUT ---
FluxTextField(
label: context.l10n.authScreenBusinessEmail,
icon: Icons.email_outlined,
controller: _emailController,
keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.email,
AutofillHints.username,
],
),
const SizedBox(height: 20),
FluxTextField(
label: 'Password',
icon: Icons.lock_outline,
isPassword: true, // Magia del FluxTextField!
controller: _passwordController,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) =>
_submit(), // Se lo supporti nel tuo widget custom
),
const SizedBox(height: 40),
const SizedBox(height: 40),
// --- BOTTONE PRINCIPALE ---
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
// --- BOTTONE PRINCIPALE ---
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
state.isLoginMode
? context.l10n.authScreenLogin
: context.l10n.authScreenSignUp,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
)
: Text(
state.isLoginMode
? context.l10n.authScreenLogin
: context.l10n.authScreenSignUp,
style: const TextStyle(
),
),
// --- SWITCH LOGIN/SIGNUP ---
const SizedBox(height: 24),
TextButton(
onPressed: isLoading
? null
: () => context.read<AuthCubit>().toggleMode(),
child: RichText(
text: TextSpan(
text: state.isLoginMode
? context.l10n.authScreenDontHaveAccount
: context.l10n.authScreenAlreadyHaveAccount,
style: TextStyle(color: context.secondaryText),
children: [
TextSpan(
text: state.isLoginMode
? context.l10n.authScreenSignUp
: context.l10n.authScreenLogin,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
),
),
),
),
// --- SWITCH LOGIN/SIGNUP ---
const SizedBox(height: 24),
TextButton(
onPressed: isLoading
? null
: () => context.read<AuthCubit>().toggleMode(),
child: RichText(
text: TextSpan(
text: state.isLoginMode
? context.l10n.authScreenDontHaveAccount
: context.l10n.authScreenAlreadyHaveAccount,
style: TextStyle(color: context.secondaryText),
children: [
TextSpan(
text: state.isLoginMode
? context.l10n.authScreenSignUp
: context.l10n.authScreenLogin,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
if (state.isLoginMode) ...[
const SizedBox(height: 24),
TextButton(
onPressed: () => context
.read<AuthCubit>()
.requestPasswordReset(_emailController.text.trim()),
child: Text(
context.l10n.authScreenForgotPassword,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
],
),
),
),
if (state.isLoginMode) ...[
const SizedBox(height: 24),
TextButton(
onPressed: () =>
context.read<AuthCubit>().requestPasswordReset(
_emailController.text.trim(),
),
child: Text(
context.l10n.authScreenForgotPassword,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
),
),
),
],
],
],
),
),
),
),

View File

@@ -53,6 +53,5 @@ class LatestStoreTicketsBloc
);
}
});
// TODO: implement event handlers
}
}

View File

@@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; // Tuo percorso
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
import 'package:get_it/get_it.dart';
@@ -17,18 +17,16 @@ class StaffScreen extends StatefulWidget {
class _StaffScreenState extends State<StaffScreen> {
String? _selectedStoreId;
bool _showAllCompanyStaff = true; // Partiamo con la vista globale
bool _showAllCompanyStaff = true;
@override
void initState() {
super.initState();
// Carichiamo subito tutto
context.read<StaffCubit>().loadAllStaff();
}
@override
Widget build(BuildContext context) {
// 1. Peschiamo chi siamo noi e che poteri abbiamo
final myRole = context
.read<SessionCubit>()
.state
@@ -36,12 +34,12 @@ class _StaffScreenState extends State<StaffScreen> {
?.systemRole;
final canManageStaff =
myRole == SystemRole.admin || myRole == SystemRole.manager;
return Scaffold(
backgroundColor: context.background,
appBar: AppBar(
title: const Text("Anagrafica Personale"),
actions: [
// Toggle per vista Azienda / Negozio
Padding(
padding: const EdgeInsets.only(right: 16),
child: FilterChip(
@@ -66,7 +64,7 @@ class _StaffScreenState extends State<StaffScreen> {
},
child: Column(
children: [
// --- BARRA FILTRO NEGOZIO (Visibile solo se non 'Tutta l'Azienda') ---
// BARRA FILTRO NEGOZIO
AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: _showAllCompanyStaff ? 0 : 80,
@@ -75,7 +73,7 @@ class _StaffScreenState extends State<StaffScreen> {
: _buildStoreSelector(),
),
// --- LISTA PERSONALE ---
// LISTA PERSONALE
Expanded(
child: BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) {
@@ -87,17 +85,14 @@ class _StaffScreenState extends State<StaffScreen> {
return const Center(child: CircularProgressIndicator());
}
if (list.isEmpty) {
return _buildEmptyState();
}
if (list.isEmpty) return _buildEmptyState();
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: list.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final member = list[index];
return _buildStaffCard(member);
return _buildStaffCard(list[index]);
},
);
},
@@ -118,7 +113,6 @@ class _StaffScreenState extends State<StaffScreen> {
Widget _buildStoreSelector() {
return BlocBuilder<StoreCubit, StoreState>(
// Assumendo tu abbia uno StoreCubit
builder: (context, state) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -146,6 +140,8 @@ class _StaffScreenState extends State<StaffScreen> {
?.systemRole;
final canManageStaff =
myRole == SystemRole.admin || myRole == SystemRole.manager;
final hasEmail = member.email != null && member.email!.trim().isNotEmpty;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
@@ -156,7 +152,10 @@ class _StaffScreenState extends State<StaffScreen> {
contentPadding: const EdgeInsets.all(12),
leading: CircleAvatar(
backgroundColor: context.accent.withValues(alpha: 0.1),
child: Text(member.name[0], style: TextStyle(color: context.accent)),
child: Text(
member.name[0].toUpperCase(),
style: TextStyle(color: context.accent),
),
),
title: Text(
member.name,
@@ -165,55 +164,65 @@ class _StaffScreenState extends State<StaffScreen> {
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (member.email != null && member.email!.isNotEmpty)
Text(member.email!),
if (hasEmail) Text(member.email!),
Text(
member.phoneNumber != null && member.phoneNumber!.isNotEmpty
member.phoneNumber != null &&
member.phoneNumber!.trim().isNotEmpty
? member.phoneNumber!
: "Nessun telefono",
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (member.jobTitle != null && member.jobTitle!.isNotEmpty) ...[
Text('Qualifica: ${member.jobTitle!}'),
const SizedBox(width: 8),
],
if (canManageStaff) ...[
const SizedBox(width: 8),
if (!member.hasJoined)
ElevatedButton.icon(
icon: const Icon(Icons.send),
label: const Text("Re-invia Invito (In Attesa)"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
onPressed: () {
// Chiama la funzione di reset password mascherata da invito
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
member.email!,
);
},
)
else
OutlinedButton.icon(
icon: const Icon(Icons.lock_reset),
label: const Text("Invia Reset Password"),
onPressed: () {
// Chiama LA STESSA IDENTICA FUNZIONE!
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
member.email!,
);
},
if (member.jobTitle != null &&
member.jobTitle!.trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Qualifica: ${member.jobTitle!}',
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.w500,
),
),
],
],
),
// MODIFICA UX: Menu a tendina per le azioni (Salva spazio e previene overflow)
trailing: canManageStaff && hasEmail
? PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'invite_reset') {
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
member.email!,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Operazione richiesta, controlla l\'email!',
),
),
);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'invite_reset',
child: Row(
children: [
Icon(
!member.hasJoined ? Icons.send : Icons.lock_reset,
size: 20,
),
const SizedBox(width: 12),
Text(
!member.hasJoined
? "Re-invia Invito"
: "Reset Password",
),
],
),
),
],
)
: null,
onTap: () =>
canManageStaff ? _openStaffForm(context, member: member) : null,
),
@@ -226,7 +235,6 @@ class _StaffScreenState extends State<StaffScreen> {
final phoneController = TextEditingController(text: member?.phoneNumber);
final jobTitleController = TextEditingController(text: member?.jobTitle);
// Variabili di stato per il BottomSheet
SystemRole selectedRole = member?.systemRole ?? SystemRole.user;
List<String> tempSelectedStores =
context
@@ -263,7 +271,7 @@ class _StaffScreenState extends State<StaffScreen> {
children: [
Text(
member == null
? "Invita Collaboratore" // Cambiato il titolo per chiarezza!
? "Invita Collaboratore"
: "Modifica Collaboratore",
style: const TextStyle(
fontSize: 20,
@@ -279,16 +287,13 @@ class _StaffScreenState extends State<StaffScreen> {
),
const SizedBox(height: 16),
// Reso visivamente obbligatorio se è un nuovo utente
FluxTextField(
controller: emailController,
label: member == null
? "Email (Obbligatoria per invito)*"
: "Email",
icon: Icons.email,
enabled:
member ==
null, // UX: Di solito l'email non si cambia dopo l'invito
enabled: member == null,
),
const SizedBox(height: 16),
@@ -299,7 +304,6 @@ class _StaffScreenState extends State<StaffScreen> {
),
const SizedBox(height: 16),
// --- NOVITÀ: SCELTA DEL RUOLO E MANSIONE ---
Row(
children: [
Expanded(
@@ -382,7 +386,6 @@ class _StaffScreenState extends State<StaffScreen> {
height: 50,
child: ElevatedButton(
onPressed: () {
// Validazione di base per i nuovi inviti
if (member == null &&
emailController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -396,7 +399,7 @@ class _StaffScreenState extends State<StaffScreen> {
}
final updatedMember = StaffMemberModel(
id: member?.id, // Sarà null se è nuovo
id: member?.id,
name: nameController.text.trim(),
email: emailController.text.trim(),
phoneNumber: phoneController.text.trim(),
@@ -410,17 +413,12 @@ class _StaffScreenState extends State<StaffScreen> {
userId: GetIt.I.get<SessionCubit>().state.user!.id,
);
// --- IL BIVIO LOGICO MAGICO ---
if (member == null) {
// 1. UTENTE NUOVO -> Chiamiamo la Edge Function
// (Nota: Per i negozi, potresti dover fare una logica a parte nel Cubit
// perché l'ID del database viene generato DOPO che l'Edge Function ha finito)
context.read<StaffCubit>().inviteStaffMember(
member: updatedMember,
selectedStoreIds: tempSelectedStores,
);
} else {
// 2. UTENTE ESISTENTE -> Modifica classica
context.read<StaffCubit>().saveStaffWithStores(
member: updatedMember,
selectedStoreIds: tempSelectedStores,
@@ -434,32 +432,6 @@ class _StaffScreenState extends State<StaffScreen> {
),
),
),
/* const SizedBox(height: 16),
if (!member!.hasJoined)
ElevatedButton.icon(
icon: const Icon(Icons.send),
label: const Text("Re-invia Invito (In Attesa)"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
onPressed: () {
// Chiama la funzione di reset password mascherata da invito
context
.read<StaffCubit>()
.resetPasswordOrResendInviteLink(member.email!);
},
)
else
OutlinedButton.icon(
icon: const Icon(Icons.lock_reset),
label: const Text("Invia Reset Password"),
onPressed: () {
// Chiama LA STESSA IDENTICA FUNZIONE!
context
.read<StaffCubit>()
.resetPasswordOrResendInviteLink(member.email!);
},
), */
],
),
),

View File

@@ -54,6 +54,8 @@ class _StoreCardState extends State<StoreCard> {
),
title: Text(
widget.store.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
@@ -65,44 +67,43 @@ class _StoreCardState extends State<StoreCard> {
// context.read<StoreBloc>().add(ToggleStoreStatus(store.id, val));
},
),
onTap: () => _openStoreForm(context, store: widget.store),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Mostra quanti dipendenti ci sono (usando lo StaffCubit)
BlocBuilder<StoreCubit, StoreState>(
builder: (context, storeState) {
final staffCount =
storeState.staffByStore[widget.store.id]?.length ?? 0;
return Row(
children: [
ActionChip(
avatar: const Icon(Icons.people, size: 16),
label: Text("$staffCount Dipendenti"),
onPressed: () => _manageStoreStaff(widget.store),
),
const SizedBox(width: 16),
ActionChip(
avatar: const Icon(Icons.handshake, size: 16),
label: Text(
"${widget.store.associatedProviders.length} Providers",
padding: const EdgeInsets.all(8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Mostra quanti dipendenti ci sono (usando lo StaffCubit)
BlocBuilder<StoreCubit, StoreState>(
builder: (context, storeState) {
final staffCount =
storeState.staffByStore[widget.store.id]?.length ?? 0;
return Row(
children: [
ActionChip(
avatar: const Icon(Icons.people, size: 16),
label: Text("$staffCount Dipendenti"),
onPressed: () => _manageStoreStaff(widget.store),
),
onPressed: () => _manageStoreProviders(widget.store),
),
],
);
},
),
const SizedBox(width: 16),
TextButton.icon(
onPressed: () => _openStoreForm(context, store: widget.store),
icon: const Icon(Icons.edit, size: 18),
label: const Text("Modifica"),
),
],
const SizedBox(width: 16),
ActionChip(
avatar: const Icon(Icons.handshake, size: 16),
label: Text(
"${widget.store.associatedProviders.length} Providers",
),
onPressed: () =>
_manageStoreProviders(widget.store),
),
],
);
},
),
],
),
),
),
],

View File

@@ -248,89 +248,121 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER DEL POST-IT (Tavolozza + Azioni) ---
Row(
children: [
// Tavolozza Colori
Expanded(
child: SizedBox(
height: 40,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _noteColors.length,
itemBuilder: (context, index) {
final colorHex = _noteColors[index];
final isSelected = _selectedColor == colorHex;
final c = Color(
int.parse(
'FF${colorHex.replaceAll('#', '')}',
radix: 16,
),
);
LayoutBuilder(
builder: (context, constraints) {
// 1. Capiamo quanto spazio reale ha la finestra in questo momento
final isNarrow = constraints.maxWidth < 500;
return GestureDetector(
onTap: () {
setState(() => _selectedColor = colorHex);
_triggerAutoSave();
},
child: Container(
margin: const EdgeInsets.only(right: 12),
width: 40,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? Colors.black54
: Colors.black12,
width: isSelected ? 3 : 1,
),
// 2. Adattiamo la dimensione dei cerchi
final double circleSize = isNarrow ? 32.0 : 40.0;
// -- PREPARIAMO IL BLOCCO COLORI --
final colorPalette = SizedBox(
height: circleSize,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _noteColors.length,
itemBuilder: (context, index) {
final colorHex = _noteColors[index];
final isSelected = _selectedColor == colorHex;
final c = Color(
int.parse(
'FF${colorHex.replaceAll('#', '')}',
radix: 16,
),
);
return GestureDetector(
onTap: () {
setState(() => _selectedColor = colorHex);
_triggerAutoSave();
},
child: Container(
margin: const EdgeInsets.only(right: 12),
width: circleSize,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? Colors.black54
: Colors.black12,
width: isSelected ? 3 : 1,
),
child: isSelected
? const Icon(
Icons.check,
color: Colors.black54,
size: 20,
)
: null,
),
);
child: isSelected
? Icon(
Icons.check,
color: Colors.black54,
size: isNarrow ? 16 : 20,
)
: null,
),
);
},
),
);
// -- PREPARIAMO IL BLOCCO AZIONI --
final actionButtons = Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.delete_outline,
color: Colors.black87,
),
tooltip: 'Elimina',
onPressed: _deleteNote,
),
IconButton(
icon: Icon(
_isPinned
? Icons.push_pin
: Icons.push_pin_outlined,
color: Colors.black87,
),
tooltip: _isPinned
? 'Rimuovi in alto'
: 'Fissa in alto',
onPressed: () {
setState(() => _isPinned = !_isPinned);
_triggerAutoSave();
},
),
),
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(
Icons.ios_share,
color: Colors.black87,
),
tooltip: 'Esporta',
onPressed: _exportNote,
),
],
);
// Azioni spostate dentro la nota!
IconButton(
icon: const Icon(
Icons.delete_outline,
color: Colors.black87,
),
tooltip: 'Elimina',
onPressed: _deleteNote,
),
IconButton(
icon: Icon(
_isPinned ? Icons.push_pin : Icons.push_pin_outlined,
color: Colors.black87,
),
tooltip: _isPinned
? 'Rimuovi in alto'
: 'Fissa in alto',
onPressed: () {
setState(() => _isPinned = !_isPinned);
_triggerAutoSave();
},
),
IconButton(
icon: const Icon(
Icons.ios_share,
color: Colors.black87,
),
tooltip: 'Esporta',
onPressed: _exportNote,
),
],
// 3. DECIDIAMO IL LAYOUT FINALE IN BASE ALLO SPAZIO REALE
if (isNarrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
actionButtons,
const SizedBox(height: 8),
colorPalette,
],
);
}
// Layout "Largo" (Finestra intera)
return Row(
children: [
Expanded(child: colorPalette),
const SizedBox(width: 16),
actionButtons,
],
);
},
),
const SizedBox(height: 32),

View File

@@ -483,36 +483,39 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
icon: Icons.design_services,
themeColor: Colors.deepOrange,
children: [
Row(
children: [
ChoiceChip(
label: const Text('Privato (Domestico)'),
selected: !state.operation.isBusiness,
selectedColor: Colors.blue.withValues(alpha: 0.2),
checkmarkColor: Colors.blue.shade700,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().updateFields(
isBusiness: false,
);
}
},
),
const SizedBox(width: 12),
ChoiceChip(
label: const Text('Business (P.IVA)'),
selected: state.operation.isBusiness,
selectedColor: Colors.orange.withValues(alpha: 0.2),
checkmarkColor: Colors.orange.shade700,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().updateFields(
isBusiness: true,
);
}
},
),
],
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
ChoiceChip(
label: const Text('Privato (Domestico)'),
selected: !state.operation.isBusiness,
selectedColor: Colors.blue.withValues(alpha: 0.2),
checkmarkColor: Colors.blue.shade700,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().updateFields(
isBusiness: false,
);
}
},
),
const SizedBox(width: 12),
ChoiceChip(
label: const Text('Business (P.IVA)'),
selected: state.operation.isBusiness,
selectedColor: Colors.orange.withValues(alpha: 0.2),
checkmarkColor: Colors.orange.shade700,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().updateFields(
isBusiness: true,
);
}
},
),
],
),
),
const Divider(height: 32),
Wrap(

View File

@@ -8,151 +8,163 @@ class DocumentSequenceSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final year = DateTime.now().year;
return BlocBuilder<DocumentSequenceCubit, DocumentSequenceState>(
builder: (context, state) {
if (state.status == DocumentSequenceStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Text(
"Protocolli e Numerazione",
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
// Invece di mappare state.sequences, mappiamo i documenti supportati
...DocumentType.values.map((docType) {
// Cerchiamo se c'è già una configurazione nello stato per questo documento
final existingList = state.sequences
.where((s) => s.docType == docType.name)
.toList();
final existingSeq = existingList.isNotEmpty
? existingList.first
: null;
// Se esiste usiamo i suoi valori, altrimenti i default
final prefix = existingSeq?.prefix ?? docType.defaultPrefix;
final nextValue = existingSeq?.nextValue ?? 1;
// Anteprima dinamica (aggiornata a 4 zeri come nel DB!)
final preview =
"${prefix.isNotEmpty ? '$prefix-' : ''}$year-${nextValue.toString().padLeft(4, '0')}";
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
docType.label,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
initialValue: prefix,
decoration: const InputDecoration(
labelText: 'Prefisso',
hintText: 'es. TCK',
),
onChanged: (val) => context
.read<DocumentSequenceCubit>()
.updateLocalSequence(
docType.name,
prefix: val,
),
),
),
const SizedBox(width: 16),
Expanded(
flex: 3,
child: TextFormField(
initialValue: nextValue.toString(),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Prossimo Numero',
),
onChanged: (val) => context
.read<DocumentSequenceCubit>()
.updateLocalSequence(
docType.name,
nextValue: int.tryParse(val) ?? 1,
),
),
),
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors
.grey
.shade100, // Se hai un tema scuro potresti voler usare Theme.of(context).colorScheme.surfaceContainer
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(
Icons.visibility,
size: 16,
color: Colors.grey,
),
const SizedBox(width: 8),
Text(
"Anteprima prossimo: ",
style: TextStyle(
color: Colors
.grey
.shade700, // Idem per la dark mode
fontSize: 12,
),
),
Text(
preview,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
],
),
),
],
),
),
);
}),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () =>
context.read<DocumentSequenceCubit>().saveSequences(),
icon: const Icon(Icons.save),
label: const Text("SALVA PROTOCOLLI"),
),
),
],
return LayoutBuilder(
builder: ((context, constraints) {
final isLargeScreen = constraints.maxWidth >= 600;
return _buildMainContent(
state: state,
isLargeScreen: isLargeScreen,
context: context,
);
}),
);
},
);
}
Widget _buildMainContent({
required BuildContext context,
required DocumentSequenceState state,
required bool isLargeScreen,
}) {
final year = DateTime.now().year;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Text(
"Protocolli e Numerazione",
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
// Invece di mappare state.sequences, mappiamo i documenti supportati
...DocumentType.values.map((docType) {
// Cerchiamo se c'è già una configurazione nello stato per questo documento
final existingList = state.sequences
.where((s) => s.docType == docType.name)
.toList();
final existingSeq = existingList.isNotEmpty
? existingList.first
: null;
// Se esiste usiamo i suoi valori, altrimenti i default
final prefix = existingSeq?.prefix ?? docType.defaultPrefix;
final nextValue = existingSeq?.nextValue ?? 1;
// Anteprima dinamica (aggiornata a 4 zeri come nel DB!)
final preview =
"${prefix.isNotEmpty ? '$prefix-' : ''}$year-${nextValue.toString().padLeft(4, '0')}";
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
docType.label,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
initialValue: prefix,
decoration: const InputDecoration(
labelText: 'Prefisso',
hintText: 'es. TCK',
),
onChanged: (val) => context
.read<DocumentSequenceCubit>()
.updateLocalSequence(docType.name, prefix: val),
),
),
const SizedBox(width: 16),
Expanded(
flex: 3,
child: TextFormField(
initialValue: nextValue.toString(),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Prossimo Numero',
),
onChanged: (val) => context
.read<DocumentSequenceCubit>()
.updateLocalSequence(
docType.name,
nextValue: int.tryParse(val) ?? 1,
),
),
),
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(
Icons.visibility,
size: 16,
color: Colors.grey,
),
const SizedBox(width: 8),
Text(
"Anteprima prossimo: ",
style: TextStyle(
color:
Colors.grey.shade700, // Idem per la dark mode
fontSize: 12,
),
),
Flexible(
child: Text(
preview,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
),
],
),
),
],
),
),
);
}),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () =>
context.read<DocumentSequenceCubit>().saveSequences(),
icon: const Icon(Icons.save),
label: const Text("SALVA PROTOCOLLI"),
),
),
],
);
}
}

View File

@@ -54,9 +54,11 @@ class SettingsScreen extends StatelessWidget {
children: [
const Icon(Icons.person, color: FluxColors.primaryBlue),
const SizedBox(width: 12),
Text(
'Modalità utente singolo (dispositivo personale)',
style: Theme.of(context).textTheme.titleLarge,
Flexible(
child: Text(
'Modalità utente singolo (dispositivo personale)',
style: Theme.of(context).textTheme.titleLarge,
),
),
],
),

View File

@@ -138,6 +138,8 @@ class TicketFormCubit extends Cubit<TicketFormState> {
String? assignedToId,
String? assignedToName,
WarrantyType? warrantyType,
DateTime? estimatedDeliveryAt,
bool clearEstimatedDelivery = false,
}) {
emit(
state.copyWith(
@@ -162,6 +164,8 @@ class TicketFormCubit extends Cubit<TicketFormState> {
assignedToId: assignedToId ?? state.ticket.assignedToId,
assignedToName: assignedToName ?? state.ticket.assignedToName,
warrantyType: warrantyType ?? state.ticket.warrantyType,
estimatedDeliveryAt: estimatedDeliveryAt,
clearEstimatedDelivery: clearEstimatedDelivery,
),
),
);

View File

@@ -1,13 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/tickets/data/ticket_repository.dart';
import 'package:flux/features/tracking/data/tracking_repository.dart';
import 'package:flux/features/tracking/models/tracking_model.dart';
import 'package:get_it/get_it.dart';
import 'ticket_list_state.dart';
class TicketListCubit extends Cubit<TicketListState> {
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
final TrackingRepository _trackingRepository = GetIt.I
.get<TrackingRepository>();
static const int _limit = 20; // Paginazione a blocchi di 20
TicketListCubit() : super(const TicketListState()) {
@@ -95,4 +98,64 @@ class TicketListCubit extends Cubit<TicketListState> {
void selectAll(List<TicketModel> tickets) {
emit(state.copyWith(selectedTickets: tickets.toSet()));
}
Future<void> closeTicketsBulk({
required List<String> ticketIds,
Map<String, bool>? loanReturns,
}) async {
// 1. Escludiamo i ticket per cui NON è stato restituito il muletto
if (loanReturns != null) {
for (final map in loanReturns.entries) {
if (!map.value) {
ticketIds.remove(map.key);
}
}
}
// Se non c'è più nulla da chiudere (es. ha rifiutato tutto), usciamo
if (ticketIds.isEmpty) {
clearSelection();
return;
}
// 2. Prepariamo i ticket per il DB
final List<TicketModel> ticketsToUpdate = [];
for (final ticketId in ticketIds) {
final ticket = state.tickets
.firstWhere((ticket) => ticket.id == ticketId)
.copyWith(ticketStatus: TicketStatus.closed);
ticketsToUpdate.add(ticket);
}
// 3. Salviamo su DB (in background)
for (final ticket in ticketsToUpdate) {
await _repository.updateTicket(ticket);
await _trackingRepository.logQuickEvent(
companyId: ticket.companyId,
message: 'Ticket chiuso - Riconsegnato',
type: TrackingType.statusChange,
parentId: ticket.id!,
parentType: TrackingParentType.ticket,
);
}
// 4. LA MAGIA: AGGIORNAMENTO LOCALE ISTANTANEO
final updatedTickets = state.tickets.map((t) {
if (ticketIds.contains(t.id)) {
return t.copyWith(ticketStatus: TicketStatus.closed);
}
return t;
}).toList();
// 5. Emettiamo il nuovo stato aggiornato e puliamo la selezione in un colpo solo
emit(
state.copyWith(
tickets: updatedTickets,
selectedTickets: {}, // Equivalente di clearSelection()
),
);
// Opzionale: Se vuoi comunque riallinearti al server in modo silenzioso dopo l'animazione
// loadTickets(refresh: true);
}
}

View File

@@ -259,6 +259,18 @@ class TicketRepository {
}
}
/// Chiude i ticket in bulk
Future<void> closeTickets(List<String> ticketIds) async {
try {
await _supabase
.from(_tableName)
.update({'ticket_status': TicketStatus.closed.value})
.inFilter('id', ticketIds);
} catch (e) {
throw Exception('Errore nella chiusura dei ticket: $e');
}
}
/// Elimina (o annulla) un ticket
Future<void> deleteTicket(String ticketId) async {
try {

View File

@@ -203,6 +203,7 @@ class TicketModel extends Equatable {
TicketType? ticketType,
TicketStatus? ticketStatus,
DateTime? estimatedDeliveryAt,
bool clearEstimatedDelivery = false,
TicketResult? ticketResult,
String? resolutionNotes,
String? shippingDocumentId,
@@ -242,7 +243,9 @@ class TicketModel extends Equatable {
hasCourtesyDevice: hasCourtesyDevice ?? this.hasCourtesyDevice,
ticketType: ticketType ?? this.ticketType,
ticketStatus: ticketStatus ?? this.ticketStatus,
estimatedDeliveryAt: estimatedDeliveryAt ?? this.estimatedDeliveryAt,
estimatedDeliveryAt: clearEstimatedDelivery
? null
: (estimatedDeliveryAt ?? this.estimatedDeliveryAt),
ticketResult: ticketResult ?? this.ticketResult,
resolutionNotes: resolutionNotes ?? this.resolutionNotes,
shippingDocumentId: shippingDocumentId ?? this.shippingDocumentId,

View File

@@ -141,6 +141,55 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
}
}
// Formatta in "GG/MM/AAAA HH:MM"
String _formatDateTime(DateTime dt) {
return "${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}";
}
// Lancia i popup di Data e poi di Ora
Future<void> _selectDeliveryDate(
BuildContext context,
TicketModel ticket,
) async {
final initialDate = ticket.estimatedDeliveryAt ?? DateTime.now();
// 1. Chiediamo la Data
final pickedDate = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: DateTime(
2020,
), // Oppure DateTime.now() se non vuoi date passate
lastDate: DateTime(2100),
);
if (pickedDate == null) return; // L'utente ha annullato
// 2. Chiediamo l'Ora
if (!context.mounted) return;
final pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(initialDate),
);
if (pickedTime == null) return; // L'utente ha annullato
// 3. Fondiamo Data e Ora in un unico DateTime
final finalDateTime = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime.hour,
pickedTime.minute,
);
// 4. Aggiorniamo il Cubit
if (!context.mounted) return;
context.read<TicketFormCubit>().updateFields(
estimatedDeliveryAt: finalDateTime,
);
}
Future<String?> _generateIdForQr() async {
// 1. Validiamo i campi obbligatori (es. il cliente)
if (!_formKey.currentState!.validate()) return null;
@@ -784,6 +833,7 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
children: [
Expanded(
child: DropdownButtonFormField<TicketType>(
isExpanded: true,
initialValue: ticket.ticketType,
decoration: const InputDecoration(
labelText: 'Tipo Lavorazione',
@@ -804,6 +854,7 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<TicketStatus>(
isExpanded: true,
initialValue: ticket.ticketStatus,
decoration: const InputDecoration(labelText: 'Stato Attuale'),
items: TicketStatus.values
@@ -815,6 +866,37 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
),
],
),
const SizedBox(height: 16),
TextFormField(
readOnly: true, // MAGIA: Impedisce l'apertura della tastiera
// Creiamo un controller "al volo" solo per mostrargli la stringa
controller: TextEditingController(
text: ticket.estimatedDeliveryAt != null
? _formatDateTime(ticket.estimatedDeliveryAt!)
: '',
),
decoration: InputDecoration(
labelText: 'Riconsegna prevista (Data e Ora)',
prefixIcon: const Icon(Icons.event_available),
// Bottone con la X per rimuovere la data se il cliente ti dice "fai con calma"
suffixIcon: ticket.estimatedDeliveryAt != null
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
// NOTA: Dovrai assicurarti che il tuo Cubit gestisca il reset.
// O passi un flag come clearEstimatedDelivery: true,
// o gestisci il null se il tuo updateFields lo permette.
context.read<TicketFormCubit>().updateFields(
clearEstimatedDelivery:
true, // Esempio di flag da aggiungere nel Cubit
);
},
)
: null,
),
// Quando tappi il campo di testo, partono i calendari
onTap: () => _selectDeliveryDate(context, ticket),
),
if (ticket.ticketType == TicketType.repair) ...[
const SizedBox(height: 16),
DropdownButtonFormField<WarrantyType>(
@@ -1001,11 +1083,15 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
child: Icon(icon, color: themeColor),
),
const SizedBox(width: 12),
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: themeColor,
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: themeColor,
),
),
),
],

View File

@@ -46,6 +46,11 @@ class _TicketListScreenState extends State<TicketListScreen> {
appBar: AppBar(
title: const Text('Assistenza & Riparazioni'),
actions: [
IconButton(
onPressed: () =>
context.read<TicketListCubit>().loadTickets(refresh: true),
icon: const Icon(Icons.refresh),
),
// Tasto per filtri avanzati (Data, Staff, Tipo) -> Da fare in un BottomSheet!
IconButton(
icon: const Icon(Icons.filter_list),

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
class LoanPhoneReturnDialog extends StatefulWidget {
final List<TicketModel> ticketsWithLoans;
const LoanPhoneReturnDialog({super.key, required this.ticketsWithLoans});
@override
State<LoanPhoneReturnDialog> createState() => _LoanPhoneReturnDialogState();
}
class _LoanPhoneReturnDialogState extends State<LoanPhoneReturnDialog> {
// Mappa per tenere traccia delle scelte: { ticketId: true/false }
final Map<String, bool> _returnStatuses = {};
@override
void initState() {
super.initState();
// Inizializziamo tutto a "true" (di default presumiamo che lo stia restituendo)
for (var ticket in widget.ticketsWithLoans) {
_returnStatuses[ticket.id!] = true;
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Telefoni di cortesia'),
],
),
content: SizedBox(
width: double.maxFinite,
// Usiamo ListView.builder in caso ce ne siano tanti
child: ListView.separated(
shrinkWrap: true,
itemCount: widget.ticketsWithLoans.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final ticket = widget.ticketsWithLoans[index];
final customerName = ticket.customer?.name ?? 'Cliente';
return SwitchListTile(
title: Text(
'$customerName ha un telefono di cortesia in prestito.',
),
subtitle: const Text('Confermi la riconsegna?'),
value: _returnStatuses[ticket.id!] ?? true,
activeThumbColor: Theme.of(context).colorScheme.primary,
onChanged: (bool value) {
setState(() {
_returnStatuses[ticket.id!] = value;
});
},
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null), // Annulla tutto
child: const Text('Annulla'),
),
FilledButton(
onPressed: () =>
Navigator.of(context).pop(_returnStatuses), // Passa la mappa
child: const Text('Conferma'),
),
],
);
}
}

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart';
import 'package:flux/features/tickets/blocs/ticket_list_state.dart';
import 'package:flux/features/tickets/blocs/ticket_shipping_cubit.dart';
import 'package:flux/features/tickets/ui/widgets/loan_phone_return_dialog.dart';
import 'package:flux/features/tickets/ui/widgets/ticket_list_card.dart';
import 'package:flux/features/tickets/ui/widgets/ticket_shipping_modal.dart';
@@ -17,6 +17,67 @@ class TicketList extends StatelessWidget {
required this.state,
});
void _showShippingModal(BuildContext context) async {
// 1. Apriamo la modale e ASPETTIAMO il risultato (tipizzandolo come Record)
final bool? result = await showModalBottomSheet<bool?>(
context: context,
isScrollControlled: true,
builder: (context) {
return BlocProvider(
create: (context) =>
TicketShippingCubit(tickets: state.selectedTickets.toList())
..loadRepairCenters(),
child: TicketShippingModal(
ticketIds: state.selectedTickets.map((t) => t.id!).toList(),
),
);
},
);
// 2. Se l'utente ha chiuso trascinando giù, result è null.
// Se ha salvato con successo, result contiene il nostro Record!
if (result != null && context.mounted) {
// 5. Pulizia finale: Deselezioniamo tutti i ticket e ricarichiamo la lista
context.read<TicketListCubit>().clearSelection();
// (Se necessario, chiama il metodo per ricaricare la lista dei ticket dal DB)
context.read<TicketListCubit>().loadTickets(refresh: true);
}
}
void _setStatusClosed(BuildContext context) async {
// 1. Filtriamo solo i ticket che hanno un telefono in prestito
final ticketsWithLoans = state.selectedTickets
.where((t) => t.hasCourtesyDevice == true)
.toList();
// Prepariamo la variabile per contenere i telefoni restituiti (se ce ne sono)
Map<String, bool>? loanReturns;
// 2. Se ci sono telefoni in prestito, mostriamo il popup
if (ticketsWithLoans.isNotEmpty) {
loanReturns = await showDialog<Map<String, bool>>(
context: context,
builder: (context) =>
LoanPhoneReturnDialog(ticketsWithLoans: ticketsWithLoans),
);
// Se l'utente ha premuto fuori o ha fatto "Annulla", blocchiamo l'operazione bulk
if (loanReturns == null) return;
}
// 3. Se siamo qui, o non c'erano muletti, o l'utente ha confermato il popup.
// Lanciamo l'azione sul Cubit! (Dovrai creare/adattare questo metodo nel tuo Cubit)
if (context.mounted) {
final ticketIds = state.selectedTickets.map((t) => t.id!).toList();
// Passiamo gli ID dei ticket da chiudere e la mappa delle restituzioni
context.read<TicketListCubit>().closeTicketsBulk(
ticketIds: ticketIds,
loanReturns: loanReturns, // Può essere null se non c'erano muletti
);
}
}
@override
Widget build(BuildContext context) {
return Stack(
@@ -56,75 +117,83 @@ class TicketList extends StatelessWidget {
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
bottom: state.selectedTickets.isNotEmpty
? 90
: -100, // Nasconde o mostra
left: 16,
right: 16,
child: Card(
elevation: 8,
color: Theme.of(context).colorScheme.inverseSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () =>
context.read<TicketListCubit>().clearSelection(),
bottom: state.selectedTickets.isNotEmpty ? 90 : -100,
// Mettiamo left e right a 0 per far occupare tutta la larghezza invisibile
left: 0,
right: 0,
child: Align(
alignment: Alignment.bottomCenter,
// 1. IL LIMITE MASSIMO: Su desktop non supererà mai i 600px, su mobile si restringe da solo
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Card(
elevation: 8,
color: Theme.of(context).colorScheme.inverseSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
16,
), // Qui possiamo giocare coi bordi
),
Text(
'${state.selectedTickets.length} selezionati',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
// 2. LA ROW PRINCIPALE: Spinge tutto ai due estremi del nostro "dock"
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// BLOCCO SINISTRO: Chiusura e Contatore
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close),
color: Theme.of(
context,
).colorScheme.onInverseSurface,
onPressed: () => context
.read<TicketListCubit>()
.clearSelection(),
),
const SizedBox(width: 8),
Text(
'${state.selectedTickets.length} selezionati',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Theme.of(
context,
).colorScheme.onInverseSurface,
),
),
],
),
// BLOCCO DESTRO: Wrap confinato solo ai bottoni
Wrap(
spacing: 8.0,
runSpacing: 8.0,
alignment: WrapAlignment.end,
children: [
IconButton.filled(
tooltip: 'Riconsegna',
onPressed: () => _setStatusClosed(context),
icon: const Icon(Icons.approval),
),
IconButton.filled(
tooltip: 'Spedisci',
onPressed: () => _showShippingModal(context),
icon: const Icon(Icons.local_shipping),
),
],
),
],
),
),
const Spacer(),
// IL NOSTRO FAMOSO BOTTONE SPEDISCI
// IL BOTTONE SPEDISCI NELLA BARRA IN BASSO
FilledButton.icon(
onPressed: () async {
// 1. Apriamo la modale e ASPETTIAMO il risultato (tipizzandolo come Record)
final bool? result = await showModalBottomSheet<bool?>(
context: context,
isScrollControlled: true,
builder: (context) {
return BlocProvider(
create: (context) => TicketShippingCubit(
tickets: state.selectedTickets.toList(),
)..loadRepairCenters(),
child: TicketShippingModal(
ticketIds: state.selectedTickets
.map((t) => t.id!)
.toList(),
),
);
},
);
// 2. Se l'utente ha chiuso trascinando giù, result è null.
// Se ha salvato con successo, result contiene il nostro Record!
if (result != null && context.mounted) {
// 5. Pulizia finale: Deselezioniamo tutti i ticket e ricarichiamo la lista
context.read<TicketListCubit>().clearSelection();
// (Se necessario, chiama il metodo per ricaricare la lista dei ticket dal DB)
context.read<TicketListCubit>().loadTickets(
refresh: true,
);
}
},
icon: const Icon(Icons.local_shipping),
label: const Text('Spedisci'),
),
],
),
),
),
),

View File

@@ -306,12 +306,14 @@ class _GlobalUpdateCheckerState extends State<GlobalUpdateChecker> {
size: 32,
),
const SizedBox(width: 12),
Text(
kIsWeb
? "Aggiornamento"
: "Aggiornamento Obbligatorio",
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
Flexible(
child: Text(
kIsWeb
? "Aggiornamento"
: "Aggiornamento Obbligatorio",
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
),
],
),
@@ -351,12 +353,10 @@ class _GlobalUpdateCheckerState extends State<GlobalUpdateChecker> {
onPressed: () async {
if (_updateUrl != null) {
final url = Uri.parse(_updateUrl!);
if (await canLaunchUrl(url)) {
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
}
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
}
},
),

View File

@@ -1,7 +1,7 @@
name: flux
description: "Gestione attività negozio di telefonia"
publish_to: 'none'
version: 1.0.12+12
version: 1.0.18+18
environment:
sdk: ^3.11.3