Compare commits
8 Commits
refactor-n
...
2afe97c6db
| Author | SHA1 | Date | |
|---|---|---|---|
| 2afe97c6db | |||
| 4101b736e6 | |||
| b67354610d | |||
| b19c91a7dd | |||
| 9b5d19b926 | |||
| aad9a991c2 | |||
| 7f0d18eed1 | |||
| 879c848d77 |
@@ -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)
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +74,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: AutofillGroup(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -106,6 +112,10 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
icon: Icons.email_outlined,
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [
|
||||
AutofillHints.email,
|
||||
AutofillHints.username,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FluxTextField(
|
||||
@@ -113,6 +123,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
icon: Icons.lock_outline,
|
||||
isPassword: true, // Magia del FluxTextField!
|
||||
controller: _passwordController,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
onSubmitted: (_) =>
|
||||
_submit(), // Se lo supporti nel tuo widget custom
|
||||
),
|
||||
@@ -175,9 +186,10 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
if (state.isLoginMode) ...[
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
onPressed: () => context
|
||||
.read<AuthCubit>()
|
||||
.requestPasswordReset(_emailController.text.trim()),
|
||||
onPressed: () =>
|
||||
context.read<AuthCubit>().requestPasswordReset(
|
||||
_emailController.text.trim(),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.authScreenForgotPassword,
|
||||
style: TextStyle(
|
||||
@@ -191,6 +203,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -53,6 +53,5 @@ class LatestStoreTicketsBloc
|
||||
);
|
||||
}
|
||||
});
|
||||
// TODO: implement event handlers
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
if (member.jobTitle != null &&
|
||||
member.jobTitle!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Qualifica: ${member.jobTitle!}',
|
||||
style: TextStyle(
|
||||
color: context.accent,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
// 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: [
|
||||
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,
|
||||
Icon(
|
||||
!member.hasJoined ? Icons.send : Icons.lock_reset,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
// Chiama la funzione di reset password mascherata da invito
|
||||
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
|
||||
member.email!,
|
||||
);
|
||||
},
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
!member.hasJoined
|
||||
? "Re-invia Invito"
|
||||
: "Reset Password",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
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!,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
: 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!);
|
||||
},
|
||||
), */
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,10 +67,13 @@ 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),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -90,21 +95,17 @@ class _StoreCardState extends State<StoreCard> {
|
||||
label: Text(
|
||||
"${widget.store.associatedProviders.length} Providers",
|
||||
),
|
||||
onPressed: () => _manageStoreProviders(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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -248,12 +248,17 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- HEADER DEL POST-IT (Tavolozza + Azioni) ---
|
||||
Row(
|
||||
children: [
|
||||
// Tavolozza Colori
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 1. Capiamo quanto spazio reale ha la finestra in questo momento
|
||||
final isNarrow = constraints.maxWidth < 500;
|
||||
|
||||
// 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,
|
||||
@@ -274,7 +279,7 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
width: 40,
|
||||
width: circleSize,
|
||||
decoration: BoxDecoration(
|
||||
color: c,
|
||||
shape: BoxShape.circle,
|
||||
@@ -286,21 +291,22 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: Colors.black54,
|
||||
size: 20,
|
||||
size: isNarrow ? 16 : 20,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
);
|
||||
|
||||
// Azioni spostate dentro la nota!
|
||||
// -- PREPARIAMO IL BLOCCO AZIONI --
|
||||
final actionButtons = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
@@ -311,7 +317,9 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isPinned ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
_isPinned
|
||||
? Icons.push_pin
|
||||
: Icons.push_pin_outlined,
|
||||
color: Colors.black87,
|
||||
),
|
||||
tooltip: _isPinned
|
||||
@@ -331,6 +339,30 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
|
||||
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),
|
||||
|
||||
|
||||
@@ -483,7 +483,9 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
||||
icon: Icons.design_services,
|
||||
themeColor: Colors.deepOrange,
|
||||
children: [
|
||||
Row(
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Privato (Domestico)'),
|
||||
@@ -514,6 +516,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
|
||||
@@ -8,14 +8,31 @@ 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 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: [
|
||||
@@ -23,6 +40,7 @@ class DocumentSequenceSection extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Text(
|
||||
"Protocolli e Numerazione",
|
||||
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
@@ -74,10 +92,7 @@ class DocumentSequenceSection extends StatelessWidget {
|
||||
),
|
||||
onChanged: (val) => context
|
||||
.read<DocumentSequenceCubit>()
|
||||
.updateLocalSequence(
|
||||
docType.name,
|
||||
prefix: val,
|
||||
),
|
||||
.updateLocalSequence(docType.name, prefix: val),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -103,9 +118,7 @@ class DocumentSequenceSection extends StatelessWidget {
|
||||
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
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
@@ -119,19 +132,20 @@ class DocumentSequenceSection extends StatelessWidget {
|
||||
Text(
|
||||
"Anteprima prossimo: ",
|
||||
style: TextStyle(
|
||||
color: Colors
|
||||
.grey
|
||||
.shade700, // Idem per la dark mode
|
||||
color:
|
||||
Colors.grey.shade700, // Idem per la dark mode
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Flexible(
|
||||
child: Text(
|
||||
preview,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -152,7 +166,5 @@ class DocumentSequenceSection extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,10 +54,12 @@ class SettingsScreen extends StatelessWidget {
|
||||
children: [
|
||||
const Icon(Icons.person, color: FluxColors.primaryBlue),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Modalità utente singolo (dispositivo personale)',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,13 +1083,17 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
|
||||
child: Icon(icon, color: themeColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: themeColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,76 +117,84 @@ 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,
|
||||
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),
|
||||
borderRadius: BorderRadius.circular(
|
||||
16,
|
||||
), // Qui possiamo giocare coi bordi
|
||||
),
|
||||
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),
|
||||
onPressed: () =>
|
||||
context.read<TicketListCubit>().clearSelection(),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onInverseSurface,
|
||||
onPressed: () => context
|
||||
.read<TicketListCubit>()
|
||||
.clearSelection(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${state.selectedTickets.length} selezionati',
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onInverseSurface,
|
||||
),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -306,13 +306,15 @@ class _GlobalUpdateCheckerState extends State<GlobalUpdateChecker> {
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
Flexible(
|
||||
child: Text(
|
||||
kIsWeb
|
||||
? "Aggiornamento"
|
||||
: "Aggiornamento Obbligatorio",
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -351,13 +353,11 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user