28 Commits

Author SHA1 Message Date
8ad2b7cf7e operation reference not mandatory
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m57s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m14s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 8m52s
2026-06-02 13:28:24 +02:00
3210b4fcfa default provider
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m59s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m22s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-06-02 13:12:21 +02:00
a51ac8fe7f ticket refinements 2026-06-02 11:52:31 +02:00
7fad6ee02b pw reset con edge function
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m24s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m14s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-06-02 10:55:59 +02:00
6a6e792cd9 fixed reset password 2026-06-02 10:55:26 +02:00
3c33c8765a v
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 1m24s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-06-02 10:20:43 +02:00
27a5bc16bc ricostruzione sessione manuale per aggirare gorouter che distrugge il token 2026-06-02 10:20:25 +02:00
808de7b354 altra prova
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m54s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m7s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-06-02 09:39:45 +02:00
618cbc0396 prova per inviti
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m28s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m31s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-06-02 09:32:30 +02:00
67a56f2954 fix per notifiche su web 2026-06-01 10:55:28 +02:00
88b1a618bd deep link from dead app
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m41s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m12s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 7m58s
2026-06-01 10:08:44 +02:00
d989b14967 bump
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m54s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m18s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 7m59s
2026-05-31 19:06:04 +02:00
d4ff2b9a7e task notifications 2026-05-31 19:05:21 +02:00
06ee11521d v1.1.6 2026-05-31 19:04:48 +02:00
55d6429dc5 bump version
Some checks failed
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
Build and Release FLUX (Multi-Platform) / build-web (push) Has been cancelled
Build and Release FLUX (Multi-Platform) / build-android (push) Has been cancelled
2026-05-30 18:10:10 +02:00
44c85766fc notes cubit 2026-05-30 18:06:43 +02:00
b69308e1ef refactor dashboard note list 2026-05-30 16:45:12 +02:00
6394e5a2cd refactor dashboard store ticket list 2026-05-30 16:26:59 +02:00
f31ff19a74 refactor dashboard operation list e task list with applifecycle 2026-05-30 15:19:22 +02:00
064179a753 bumped version
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m45s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m9s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 8m12s
2026-05-30 13:07:21 +02:00
727eaac3d9 resend e fcm 2026-05-30 12:26:53 +02:00
bd81173559 fcm 2026-05-30 12:12:14 +02:00
9bace01b93 b 2026-05-29 19:24:40 +02:00
5ad3e12b1f b 2026-05-29 12:26:41 +02:00
6211cc6729 bump....fixato sharedattachments e notecollaborators
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m1s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m21s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 4m15s
2026-05-28 23:49:55 +02:00
f15a2aa6e6 fixes 2026-05-28 23:48:30 +02:00
aed841dc0b firma su exe windows
All checks were successful
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 5m1s
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m52s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m6s
2026-05-28 18:06:10 +02:00
221260aca3 v
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m10s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m18s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 5m34s
2026-05-28 13:57:11 +02:00
102 changed files with 4980 additions and 1648 deletions

View File

@@ -21,11 +21,27 @@ jobs:
- name: Build Flutter Windows
run: flutter build windows --release
# 1. FIRMA DELL'ESEGUIBILE RAW
- name: Firma Eseguibile Flutter
run: |
# Cerca dinamicamente signtool.exe nell'SDK di Windows per non sbagliare percorso
$Signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1).FullName
# Sostituisci il percorso con dove hai salvato fisicamente il file .pfx sul PC del runner!
& $Signtool sign /f "C:\flux_privato.pfx" /p "${{ secrets.PFX_PASSWORD }}" /fd SHA256 "build\windows\x64\runner\Release\flux.exe"
- name: Build Windows Installer
run: |
$TagVersion = "${{ github.ref_name }}".Substring(1)
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "/DMyAppVersion=$TagVersion" "win_installer.iss"
# 2. FIRMA DELL'INSTALLER GENERATO DA INNO SETUP
- name: Firma Installer
run: |
$Signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1).FullName
& $Signtool sign /f "C:\flux_privato.pfx" /p "${{ secrets.PFX_PASSWORD }}" /fd SHA256 "build\windows\installer\FluxInstaller.exe"
# Nel dubbio usiamo l'action per caricare l'asset
- name: Upload Windows Asset
uses: https://gitea.com/actions/release-action@main

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"deno.enable": true
}

View File

@@ -7,6 +7,10 @@ analyzer:
- "lib/l10n/*.dart"
- "**/*.g.dart" # Già che ci siamo escludiamo tutti i file generati (tipo quelli di JsonSerializable)
- "**/*.freezed.dart"
- "build/**"
- "ios/**"
- "macos/**"
- ".dart_tool/**"
linter:
rules:

View File

@@ -1,5 +1,8 @@
plugins {
id("com.android.application")
// START: FlutterFire Configuration
id("com.google.gms.google-services")
// END: FlutterFire Configuration
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")

View File

@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "249756116297",
"project_id": "flux-87e49",
"storage_bucket": "flux-87e49.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:249756116297:android:a2c3d37323752069cf2698",
"android_client_info": {
"package_name": "com.catellisrl.flux"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyA6-uX6504B3yofeo7YQwfQaS0cCDoZnvY"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -20,6 +20,9 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
// START: FlutterFire Configuration
id("com.google.gms.google-services") version("4.3.15") apply false
// END: FlutterFire Configuration
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}

1
firebase.json Normal file
View File

@@ -0,0 +1 @@
{"flutter":{"platforms":{"android":{"default":{"projectId":"flux-87e49","appId":"1:249756116297:android:a2c3d37323752069cf2698","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"flux-87e49","appId":"1:249756116297:ios:fe9dadca7150da16cf2698","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"macos":{"default":{"projectId":"flux-87e49","appId":"1:249756116297:ios:fe9dadca7150da16cf2698","uploadDebugSymbols":false,"fileOutput":"macos/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"flux-87e49","configurations":{"android":"1:249756116297:android:a2c3d37323752069cf2698","ios":"1:249756116297:ios:fe9dadca7150da16cf2698","macos":"1:249756116297:ios:fe9dadca7150da16cf2698","web":"1:249756116297:web:7c652e51004414b7cf2698","windows":"1:249756116297:web:b094277c2fedb425cf2698"}}}}}}

View File

@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
101B9A4BF8F30D998DDC11D4 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = D4B70082D3146D8C2B7AFA02 /* GoogleService-Info.plist */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
@@ -67,6 +68,7 @@
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AB44F93458B7D70EE383A3A9 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
BDDDA09E437D9C0E7B65B3B1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
D4B70082D3146D8C2B7AFA02 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -126,6 +128,7 @@
331C8082294A63A400263BE5 /* RunnerTests */,
F5D002C3092D87755D552D32 /* Pods */,
6A991A28CCED9666CA172E00 /* Frameworks */,
D4B70082D3146D8C2B7AFA02 /* GoogleService-Info.plist */,
);
sourceTree = "<group>";
};
@@ -267,6 +270,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
101B9A4BF8F30D998DDC11D4 /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyAllwaoNyqHsZtqfMMo9DxVS6-q7yBwWow</string>
<key>GCM_SENDER_ID</key>
<string>249756116297</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.catellisrl.flux</string>
<key>PROJECT_ID</key>
<string>flux-87e49</string>
<key>STORAGE_BUCKET</key>
<string>flux-87e49.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:249756116297:ios:fe9dadca7150da16cf2698</string>
</dict>
</plist>

View File

@@ -1,9 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/data/core_repository.dart';
import 'package:flux/features/company/models/company_model.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/master_data/store/models/store_model.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:collection/collection.dart'; // Per firstWhereOrNull
@@ -39,35 +45,28 @@ class SessionCubit extends Cubit<SessionState> {
}
try {
// 1. CHI È QUESTO UTENTE? (Vediamo se ha un profilo staff, che sia Invitato o Admin)
// Riportiamo lo stato su initial per far girare lo spinner se stiamo riprovando
emit(state.copyWith(status: SessionStatus.initial, errorMessage: null));
// WRAP DELLA LOGICA IN UN BLOCCO PROTETTO DA TIMEOUT (10 Secondi)
await Future(() async {
StaffMemberModel? staff = await _repository.getStaffMemberByUserId(
user.id,
);
CompanyModel? company;
if (staff != null) {
// --- LA MAGIA DEL SENSORE ---
if (staff.hasJoined == false) {
// È la primissima volta che entra! Aggiorniamo il DB.
await _repository.updateStaffMember(staff.id!, {'has_joined': true});
// Aggiorniamo anche il nostro modello in memoria per questa sessione
await _repository.updateStaffMember(staff.id!, {
'has_joined': true,
});
staff = staff.copyWith(hasJoined: true);
}
company = await _repository.getCompanyById(staff.companyId);
} else {
// È l'Admin in onboarding
company = await _repository.getCompanyByOwnerId(user.id);
}
// 1. Controllo Azienda
if (staff != null) {
// L'utente esiste già nel sistema! Carichiamo l'azienda per cui lavora
company = await _repository.getCompanyById(staff.companyId);
} else {
// L'utente non ha profilo. Probabilmente è l'Admin che ha appena
// fatto Sign Up e sta iniziando l'Onboarding
company = await _repository.getCompanyByOwnerId(user.id);
}
if (company == null) {
return emit(
state.copyWith(
@@ -80,7 +79,6 @@ class SessionCubit extends Cubit<SessionState> {
emit(state.copyWith(company: company));
}
// 2. Controllo Negozi
final stores = await _repository.getStoresByCompanyId(company.id!);
if (stores.isEmpty) {
return emit(
@@ -95,7 +93,6 @@ class SessionCubit extends Cubit<SessionState> {
emit(state.copyWith(currentStore: stores.first));
}
// 3. Controllo Staff (Paziente Zero)
if (staff == null) {
return emit(
state.copyWith(
@@ -107,23 +104,16 @@ class SessionCubit extends Cubit<SessionState> {
);
}
// --- TUTTO COMPLETATO: LOGICA DEL NEGOZIO DI DEFAULT ---
// Leggiamo l'ultimo negozio dalle SharedPreferences
final lastStoreId = _prefs.getString(_lastStoreKey);
// Cerchiamo quel negozio nella lista. Se non c'è (magari è stato eliminato), prendiamo il primo.
final activeStore =
stores.firstWhereOrNull((s) => s.id == lastStoreId) ?? stores.first;
// Se non avevamo il lastStoreId salvato, salviamolo ora
if (lastStoreId != activeStore.id && activeStore.id != null) {
await _prefs.setString(_lastStoreKey, activeStore.id!);
}
setIsSingleUserMode(_prefs.getBool('isSingleUserMode') ?? false);
// 4. BENVENUTO A BORDO
emit(
state.copyWith(
status: SessionStatus.authenticated,
@@ -131,20 +121,107 @@ class SessionCubit extends Cubit<SessionState> {
company: company,
currentStore: activeStore,
currentStaffMember: staff,
onboardingStep: OnboardingStep.none, // Svuotiamo l'onboarding
onboardingStep: OnboardingStep.none,
),
);
// FCM è fuori dall'await principale, quindi va bene così
_registerFcmToken(companyId: company.id!, staffId: staff.id!);
}).timeout(
const Duration(seconds: 10), // Tempo massimo concesso al server
onTimeout: () {
throw TimeoutException(
'Il server di FLUX non risponde. Controlla la connessione.',
);
},
);
} on TimeoutException catch (e) {
// 🎯 BINGO! IL TIMEOUT È SCATTATO
debugPrint("Timeout Inizializzazione: ${e.message}");
emit(
state.copyWith(status: SessionStatus.error, errorMessage: e.message),
);
} catch (e) {
// Se esplode il database, non lasciamo l'app freezata in 'initial'
// Altri errori generici del DB o di rete
debugPrint("Errore Inizializzazione: $e");
emit(
state.copyWith(
status: SessionStatus
.unauthenticated, // O un nuovo stato SessionStatus.error
status: SessionStatus.error,
errorMessage: "Si è verificato un errore di connessione imprevisto.",
),
);
}
}
Future<void> _registerFcmToken({
required String companyId,
required String staffId,
}) async {
// Scudo anti-crash per lo sviluppo su Linux / Windows
if (!kIsWeb &&
!Platform.isAndroid &&
!Platform.isIOS &&
!Platform.isMacOS) {
return;
}
try {
final messaging = FirebaseMessaging.instance;
// 1. Richiesta permessi di notifica
final settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
String? fcmToken;
if (kIsWeb) {
fcmToken = await messaging.getToken(
vapidKey:
'BLMUr7crlRghEW6iWtRZ7Y0a74OPAMG9Oh37ewhVP3_5YD9e5RHUeO79sDys6P-7KjOz6I6HiaPqNndmatQlu3g',
);
} else {
fcmToken = await messaging.getToken();
}
if (fcmToken != null) {
final supabase = GetIt.I.get<SupabaseClient>();
// Determiniamo la piattaforma in modo sicuro per Linux
String osPlatform = 'web';
if (!kIsWeb) {
if (Platform.isAndroid) osPlatform = 'android';
if (Platform.isIOS) osPlatform = 'ios';
if (Platform.isMacOS) osPlatform = 'macos';
}
// 3. UPSERT su Supabase condizionato dal vincolo 'fcm_token'
await supabase.from('staff_devices').upsert(
{
'company_id': companyId,
'staff_id': staffId,
'fcm_token': fcmToken,
'os_platform': osPlatform,
'updated_at': DateTime.now().toIso8601String(),
},
onConflict:
'fcm_token', // Se il token esiste già, aggiorna questa riga!
);
debugPrint(
'Dispositivo registrato con successo su FLUX Cloud. Platform: $osPlatform',
);
}
} else {
debugPrint('Permesso push negato dall\'utente.');
}
} catch (e) {
debugPrint('Errore durante la registrazione del dispositivo: $e');
}
}
void updateCurrentCompany(CompanyModel newCompany) {
emit(state.copyWith(company: newCompany));
}
@@ -170,4 +247,13 @@ class SessionCubit extends Cubit<SessionState> {
void setIsSingleUserMode(bool isSingleUser) {
emit(state.copyWith(isSingleUserMode: isSingleUser));
}
void updateCurrentStoreLocally(StoreModel updatedStore) {
// Verifichiamo che l'utente stia effettivamente lavorando nel negozio appena modificato
if (state.currentStore != null &&
state.currentStore!.id == updatedStore.id) {
// Emettiamo il nuovo stato sovrascrivendo solo il negozio corrente
emit(state.copyWith(currentStore: updatedStore));
}
}
}

View File

@@ -6,6 +6,7 @@ enum SessionStatus {
unauthenticated,
onboardingRequired,
authenticated,
error,
}
/// Definisce lo step esatto dell'onboarding (Paranoia Mode)
@@ -26,6 +27,7 @@ class SessionState extends Equatable {
final OnboardingStep onboardingStep;
final bool isMobileDevice;
final bool isSingleUserMode;
final String? errorMessage;
const SessionState({
this.status = SessionStatus.initial,
@@ -36,6 +38,7 @@ class SessionState extends Equatable {
this.onboardingStep = OnboardingStep.none,
this.isMobileDevice = false,
this.isSingleUserMode = false,
this.errorMessage,
});
/// Metodo per creare una copia dello stato modificando solo i campi necessari
@@ -48,6 +51,7 @@ class SessionState extends Equatable {
OnboardingStep? onboardingStep,
bool? isMobileDevice,
bool? isSingleUserMode,
String? errorMessage,
}) {
return SessionState(
status: status ?? this.status,
@@ -58,6 +62,7 @@ class SessionState extends Equatable {
onboardingStep: onboardingStep ?? this.onboardingStep,
isMobileDevice: isMobileDevice ?? this.isMobileDevice,
isSingleUserMode: isSingleUserMode ?? this.isSingleUserMode,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@@ -71,6 +76,7 @@ class SessionState extends Equatable {
onboardingStep,
isMobileDevice,
isSingleUserMode,
errorMessage,
];
// Helper rapidi per la UI

View File

@@ -7,9 +7,9 @@ import 'package:flux/core/layout/app_shell.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/widgets/image_upload/blocs/image_upload_cubit.dart';
import 'package:flux/core/widgets/image_upload/ui/image_upload_screen.dart';
import 'package:flux/core/widgets/set_password_screen.dart';
import 'package:flux/core/widgets/image_upload/ui/upload_success_screen.dart';
import 'package:flux/features/auth/ui/auth_screen.dart';
import 'package:flux/features/auth/ui/set_password_screen.dart';
import 'package:flux/features/company/bloc/company_settings_cubit.dart';
import 'package:flux/features/company/ui/company_settings_screen.dart';
import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
@@ -18,6 +18,9 @@ import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/customer_detail_screen.dart';
import 'package:flux/features/customers/ui/customer_form_screen.dart';
import 'package:flux/features/customers/ui/customers_list_screen.dart';
import 'package:flux/features/home/dashboard_note_list/blocs/dashboard_note_list_cubit.dart';
import 'package:flux/features/home/dashboard_store_operation_list/bloc/dashboard_store_operation_list_cubit.dart';
import 'package:flux/features/home/dashboard_store_ticket_list/blocs/dashboard_store_ticket_list_cubit.dart';
import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
import 'package:flux/features/home/ui/home_screen.dart';
import 'package:flux/features/master_data/master_data_hub_content.dart';
@@ -43,8 +46,10 @@ import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/operations/ui/operation_form_screen.dart';
import 'package:flux/features/operations/ui/operation_list_screen.dart';
import 'package:flux/features/settings/settings_screen.dart';
import 'package:flux/features/settings/theme_settings_view.dart';
import 'package:flux/features/settings/blocs/reminder_defaults_cubit.dart';
import 'package:flux/features/settings/ui/reminder_settings_screen.dart';
import 'package:flux/features/settings/ui/settings_screen.dart';
import 'package:flux/features/settings/ui/theme_settings_view.dart';
import 'package:flux/features/tasks/blocs/task_form_cubit.dart';
import 'package:flux/features/tasks/blocs/task_list_cubit.dart';
import 'package:flux/features/tasks/models/task_model.dart';
@@ -61,10 +66,16 @@ import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
class AppRouter {
// 1. CREIAMO LA CHIAVE GLOBALE DEL NAVIGATORE
static final GlobalKey<NavigatorState> rootNavigatorKey =
GlobalKey<NavigatorState>();
static String? pendingRoute;
static GoRouter createRouter(SessionCubit sessionCubit) {
return GoRouter(
navigatorKey: rootNavigatorKey,
initialLocation: '/',
refreshListenable: GoRouterRefreshStream(sessionCubit.stream),
redirect: (context, state) {
final sessionState = sessionCubit.state;
final isGoingToLogin = state.matchedLocation == '/login';
@@ -143,11 +154,32 @@ class AppRouter {
builder: (context, state) {
return MultiBlocProvider(
providers: [
BlocProvider<DashboardStoreOperationListCubit>(
create: (context) => DashboardStoreOperationListCubit(
companyId: sessionCubit.state.company?.id,
storeId: sessionCubit.state.currentStore?.id,
),
),
BlocProvider<DashboardTaskListCubit>(
create: (context) => DashboardTaskListCubit(),
create: (context) => DashboardTaskListCubit(
companyId: sessionCubit.state.company?.id,
staffId: sessionCubit.state.currentStaffMember?.id,
),
),
BlocProvider<DashboardStoreTicketListCubit>(
create: (context) => DashboardStoreTicketListCubit(
companyId: sessionCubit.state.company?.id,
storeId: sessionCubit.state.currentStore?.id,
),
),
BlocProvider<DashboardNoteListCubit>(
create: (context) => DashboardNoteListCubit(
companyId: sessionCubit.state.company?.id,
staffId: sessionCubit.state.currentStaffMember?.id,
),
),
],
child: HomeScreen(),
child: const HomeScreen(),
);
},
),
@@ -218,6 +250,16 @@ class AppRouter {
name: Routes.themeSettings,
builder: (context, state) => const ThemeSettingsView(),
),
GoRoute(
path: 'reminderSettings',
name: Routes.reminderSettings,
builder: (context, state) =>
BlocProvider<ReminderDefaultsCubit>(
create: (context) => ReminderDefaultsCubit(),
child: const ReminderSettingsScreen(),
),
),
],
),
@@ -541,16 +583,21 @@ class AppRouter {
realTaskId = pathId;
}
final allStaffList = context.read<StaffCubit>().state.allStaff;
List<StaffMemberModel>? preloadedStaff;
try {
preloadedStaff = context.read<StaffCubit>().state.allStaff;
} catch (_) {
preloadedStaff = null; // Fallback se la rotta è isolata
}
// Creiamo il BLoC "al volo" solo per questa schermata
return MultiBlocProvider(
providers: [
BlocProvider<TaskFormCubit>(
create: (context) => TaskFormCubit(
globalStaff: allStaffList,
initialTask: task,
existingTask: task,
initialTaskId: realTaskId,
allStaff: preloadedStaff,
),
),
],

View File

@@ -26,4 +26,5 @@ class Routes {
static const String notes = 'notes';
static const String tasks = 'tasks';
static const String taskForm = 'task-form';
static const String reminderSettings = 'reminder-settings';
}

View File

@@ -0,0 +1,36 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flux/core/routes/app_router.dart';
import 'package:go_router/go_router.dart';
// Chiamala dopo l'autenticazione o nel main()
Future<void> setupInteractedMessage() async {
// CASO A: L'app era completamente CHIUSA e viene aperta tappando la notifica
RemoteMessage? initialMessage = await FirebaseMessaging.instance
.getInitialMessage();
if (initialMessage != null) {
_handleNotificationTap(initialMessage);
}
// CASO B: L'app era in BACKGROUND (minimizzata) e l'utente tappa la notifica
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
}
void _handleNotificationTap(RemoteMessage message) {
final eventType = message.data['eventType'];
final referenceId = message.data['referenceId'];
if (eventType == 'task_assigned' && referenceId != null) {
final routePath = '/tasks/form/$referenceId';
final context = AppRouter.rootNavigatorKey.currentContext;
if (context != null) {
// Scenario A: App già aperta, naviga all'istante
context.push(routePath);
} else {
// Scenario B: App chiusa. Il contesto non c'è ancora, congeliamo la rotta!
debugPrint("App in fase di avvio. Congelo la rotta: $routePath");
AppRouter.pendingRoute = routePath;
}
}
}

View File

@@ -1,128 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:go_router/go_router.dart';
class SetPasswordScreen extends StatefulWidget {
const SetPasswordScreen({super.key});
@override
State<SetPasswordScreen> createState() => _SetPasswordScreenState();
}
class _SetPasswordScreenState extends State<SetPasswordScreen> {
final _passwordCtrl = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _savePassword() async {
final newPassword = _passwordCtrl.text.trim();
if (newPassword.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.setPasswordScreenAtLeast6Chars)),
);
return;
}
setState(() => _isLoading = true);
try {
// 1. Aggiorniamo la password dell'utente (che Supabase ha già loggato grazie al link della mail)
await GetIt.I.get<SupabaseClient>().auth.updateUser(
UserAttributes(password: newPassword),
);
// 2. Finito! Lo mandiamo alla home o facciamo ricaricare la sessione al SessionCubit
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.setPasswordScreenPasswordSetWelcome),
),
);
context.go('/'); // Rimandiamo al router principale
}
} on AuthException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.authError(e.message))),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.commonError(e.toString()))),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.setPasswordScreenWelcomeInFlux),
automaticallyImplyLeading:
false, // Non può tornare indietro, deve mettere la password!
actions: [
IconButton.filled(
onPressed: () => context.read<SessionCubit>().signOut(),
icon: Icon(Icons.logout),
),
],
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.lock_reset, size: 80, color: Colors.blueAccent),
const SizedBox(height: 24),
Text(
context.l10n.setPasswordScreenSetPassword,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
context.l10n.setPasswordInviteAcceptedChoosePassword,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 32),
FluxTextField(
controller: _passwordCtrl,
label: context.l10n.commonNewPassword,
icon: Icons.lock,
isPassword: true,
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _isLoading ? null : _savePassword,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: Text(
context.l10n.setPasswordScreenSaveAndStart,
style: TextStyle(fontSize: 16),
),
),
],
),
),
);
}
}

View File

@@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
@@ -14,7 +16,6 @@ part 'attachments_state.dart';
class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
final _repository = GetIt.I.get<AttachmentsRepository>();
final String? companyId = GetIt.I.get<SessionCubit>().state.company?.id;
AttachmentsBloc({String? parentId, required AttachmentParentType parentType})
: super(
@@ -36,8 +37,8 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
on<SelectAllAttachmentsEvent>(_onSelectAllAttachments);
on<ClearAttachmentSelectionEvent>(_onClearAttachmentSelection);
// Se il BLoC nasce già con un ID, carichiamo i file
if (parentId != null && companyId != null) {
final currentCompanyId = GetIt.I.get<SessionCubit>().state.company?.id;
if (parentId != null && currentCompanyId != null) {
add(LoadAttachmentsEvent(parentId: parentId));
}
}
@@ -46,6 +47,8 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
ParentEntitySavedEvent event,
Emitter<AttachmentsState> emit,
) async {
final companyId = GetIt.I.get<SessionCubit>().state.company?.id;
emit(
state.copyWith(
parentId: event.newParentId,
@@ -117,14 +120,30 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
Emitter<AttachmentsState> emit,
) async {
final currentId = state.parentId;
final currentCompanyId = GetIt.I.get<SessionCubit>().state.company?.id;
// BIVIO 1: PRATICA NUOVA (Salvataggio locale)
if (currentCompanyId == null) {
emit(
state.copyWith(
status: AttachmentsStatus.failure,
error: "Company ID non trovato nella sessione",
),
);
return;
}
// BIVIO 1: PRATICA NUOVA (Salvataggio locale in memoria)
if (currentId == null) {
final newLocalFiles = event.files.map((file) {
// Assegniamo i campi dinamicamente in base al parentType!
// FISCHIO SALVAVITA PER DESKTOP: se i bytes sono nulli, li leggiamo dal path fisico!
Uint8List? rawBytes = file.bytes;
if (rawBytes == null && file.path != null) {
rawBytes = File(file.path!).readAsBytesSync();
}
return AttachmentModel(
id: null,
companyId: companyId!,
companyId: currentCompanyId,
operationId: state.parentType == AttachmentParentType.operation
? ''
: null,
@@ -136,7 +155,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
extension: file.name.fileExtension(),
storagePath: '',
fileSize: file.size,
localBytes: file.bytes,
localBytes: rawBytes, // Ora i byte ci sono al 100% anche su Mac!
);
}).toList();
@@ -157,7 +176,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
parentId: currentId,
parentType: state.parentType,
pickedFile: file,
companyId: companyId!,
companyId: currentCompanyId,
bucket: _getBucketForParentType,
);
}).toList();

View File

@@ -1,14 +1,15 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/core/utils/app_message.dart';
import 'package:flux/features/master_data/staff/data/staff_repository.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
part 'auth_state.dart';
class AuthCubit extends Cubit<AuthState> {
final _supabase = GetIt.instance<SupabaseClient>();
final _staffRepository = GetIt.instance<StaffRepository>();
AuthCubit() : super(const AuthState());
@@ -95,10 +96,7 @@ class AuthCubit extends Cubit<AuthState> {
);
return;
}
await _supabase.auth.resetPasswordForEmail(
email,
redirectTo: resetPasswordUrl,
);
await _staffRepository.resetPassword(email);
emit(
state.copyWith(
status: AuthStatus.pwResetSent,

View File

@@ -128,6 +128,30 @@ class _AuthScreenState extends State<AuthScreen> {
_submit(), // Se lo supporti nel tuo widget custom
),
// Link "Dimenticato password?" solo in Login mode
if (state.isLoginMode) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: isLoading
? null
: () => context
.read<AuthCubit>()
.requestPasswordReset(
_emailController.text.trim(),
),
child: Text(
context.l10n.authScreenForgotPassword,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
),
),
),
),
],
const SizedBox(height: 40),
// --- BOTTONE PRINCIPALE ---

View File

@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:flux/main.dart';
import 'package:go_router/go_router.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class SetPasswordScreen extends StatefulWidget {
const SetPasswordScreen({super.key});
@override
State<SetPasswordScreen> createState() => _SetPasswordScreenState();
}
class _SetPasswordScreenState extends State<SetPasswordScreen> {
final _formKey = GlobalKey<FormState>();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
String? _errorMessage;
// Variabile per abilitare l'inserimento
bool _isSessionReady = false;
@override
void initState() {
super.initState();
_forceSessionRecovery();
}
@override
void dispose() {
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
// 🎯 LA VERA MAGIA: RICOSTRUIAMO LA SESSIONE A MANO
Future<void> _forceSessionRecovery() async {
try {
// 1. Prendiamo il frammento dalla cassaforte
final fragment = initialRecoveryFragment ?? Uri.base.fragment;
if (fragment.contains('access_token=')) {
// 2. Dividiamo la stringa in una mappa chiave:valore
final params = Uri.splitQueryString(fragment);
final refreshToken = params['refresh_token'];
if (refreshToken != null) {
// 3. Forziamo Supabase a loggare l'utente col refresh token!
await Supabase.instance.client.auth.setSession(refreshToken);
if (mounted) {
setState(() {
_isSessionReady = true;
_errorMessage = null;
});
}
return;
}
}
// Fallback: se Supabase ce l'aveva già fatta miracolosamente
if (Supabase.instance.client.auth.currentSession != null) {
setState(() => _isSessionReady = true);
}
} catch (e) {
debugPrint("Errore ripristino manuale sessione: $e");
}
}
Future<void> _submitNewPassword() async {
if (!_formKey.currentState!.validate()) return;
if (!_isSessionReady) {
setState(() {
_errorMessage =
"Sincronizzazione di sicurezza fallita. Il link potrebbe essere scaduto.";
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Ora questo updateUser troverà la sessione viva e vegeta!
await Supabase.instance.client.auth.updateUser(
UserAttributes(password: _passwordController.text.trim()),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Password impostata con successo! Benvenuto in FLUX.',
),
backgroundColor: Colors.green,
),
);
context.go('/');
}
} on AuthException catch (e) {
setState(() {
_errorMessage = e.message;
});
} catch (e) {
setState(() {
_errorMessage = "Si è verificato un errore imprevisto. Riprova.";
});
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
// Rendiamo la schermata responsive ed elegante per il Web (Cloudflare)
final size = MediaQuery.of(context).size;
final isWebContainer = size.width > 600;
return Scaffold(
body: Center(
child: SingleChildScrollView(
child: Container(
width: isWebContainer ? 450 : size.width,
padding: const EdgeInsets.all(32.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo o Brand FLUX
Text(
'FLUX',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: -1,
),
),
const SizedBox(height: 8),
Text(
'Configura la tua chiave di accesso per iniziare a collaborare.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
const SizedBox(height: 32),
if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red, fontSize: 13),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
],
// Campo Password
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Nuova Password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
),
border: const OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Inserisci una password';
}
if (value.length < 6) {
return 'La password deve avere almeno 6 caratteri';
}
return null;
},
),
const SizedBox(height: 16),
// Campo Conferma Password
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscurePassword,
decoration: const InputDecoration(
labelText: 'Conferma Password',
prefixIcon: Icon(Icons.lock_reset_rounded),
border: OutlineInputBorder(),
),
validator: (value) {
if (value != _passwordController.text) {
return 'Le password non coincidono';
}
return null;
},
),
const SizedBox(height: 24),
// Bottone di Invio
ElevatedButton(
onPressed: _isLoading ? null : _submitNewPassword,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.black,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: const Text(
'Conferma e Accedi',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/notes/data/notes_repository.dart';
import 'package:flux/features/notes/models/note_model.dart';
import 'package:get_it/get_it.dart';
part 'dashboard_note_list_state.dart';
class DashboardNoteListCubit extends Cubit<DashboardNoteListState> {
final NotesRepository _repository = GetIt.I.get<NotesRepository>();
final String? companyId;
final String? staffId;
StreamSubscription<void>? _subscription;
DashboardNoteListCubit({required this.companyId, required this.staffId})
: super(DashboardNoteListState(status: DashboardNoteListStatus.initial));
void stopListening() {
_subscription?.cancel();
_subscription = null;
}
void startListening() {
stopListening();
emit(state.copyWith(status: DashboardNoteListStatus.loading));
// Primo caricamento
_loadNotesSilently();
// Inizio ascolto campanello
try {
_subscription = _repository
.notesStream(companyId: companyId!, currentStaffId: staffId!)
.listen((_) {
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
_loadNotesSilently();
});
} on Exception catch (e) {
debugPrint(e.toString());
}
}
Future<void> _loadNotesSilently() async {
try {
final notes = await _repository.getNotes();
if (isClosed) return;
emit(
state.copyWith(
status: DashboardNoteListStatus.success,
notes: notes,
errorMessage: null,
),
);
} catch (e) {
if (isClosed) return;
emit(
state.copyWith(
status: DashboardNoteListStatus.failure,
errorMessage: e.toString(),
),
);
}
}
@override
Future<void> close() {
stopListening();
return super.close();
}
}

View File

@@ -0,0 +1,30 @@
part of 'dashboard_note_list_cubit.dart';
enum DashboardNoteListStatus { initial, loading, success, failure }
class DashboardNoteListState extends Equatable {
final DashboardNoteListStatus status;
final List<NoteModel> notes;
final String? errorMessage;
const DashboardNoteListState({
this.status = DashboardNoteListStatus.initial,
this.notes = const [],
this.errorMessage,
});
DashboardNoteListState copyWith({
DashboardNoteListStatus? status,
List<NoteModel>? notes,
String? errorMessage,
}) {
return DashboardNoteListState(
status: status ?? this.status,
notes: notes ?? this.notes,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [status, notes, errorMessage];
}

View File

@@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/notes/blocs/notes_bloc.dart';
import 'package:flux/features/home/dashboard_note_list/blocs/dashboard_note_list_cubit.dart';
import 'package:flux/features/notes/models/note_model.dart';
import 'package:go_router/go_router.dart'; // Supponendo tu usi GoRouter per la navigazione
class DashboardNotesWidget extends StatelessWidget {
const DashboardNotesWidget({super.key});
class DashboardNoteListCard extends StatelessWidget {
const DashboardNoteListCard({super.key});
@override
Widget build(BuildContext context) {
@@ -59,14 +59,14 @@ class DashboardNotesWidget extends StatelessWidget {
const SizedBox(height: 12),
// Il corpo del widget collegato al Bloc
BlocBuilder<NotesBloc, NotesState>(
BlocBuilder<DashboardNoteListCubit, DashboardNoteListState>(
builder: (context, state) {
if (state.status == NotesStatus.loading &&
if (state.status == DashboardNoteListStatus.loading &&
state.notes.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == NotesStatus.failure) {
if (state.status == DashboardNoteListStatus.failure) {
return const Center(
child: Text(
'Errore nel caricamento delle note.',

View File

@@ -0,0 +1,84 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:get_it/get_it.dart';
part 'dashboard_store_operation_list_state.dart';
class DashboardStoreOperationListCubit
extends Cubit<DashboardStoreOperationListState> {
final OperationsRepository _repository = GetIt.I.get<OperationsRepository>();
final String? companyId;
final String? storeId;
StreamSubscription<void>? _operationsSubscription;
DashboardStoreOperationListCubit({
required this.companyId,
required this.storeId,
}) : super(
const DashboardStoreOperationListState(
status: DashboardStoreOperationListStatus.initial,
),
);
void startListening() {
emit(state.copyWith(status: DashboardStoreOperationListStatus.loading));
stopListening();
// Primo caricamento
_loadOperationsSilently();
// Inizio ascolto campanello
try {
_operationsSubscription = _repository
.watchStoreOperations(storeId: storeId!, limit: 10)
.listen((_) {
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
_loadOperationsSilently();
});
} on Exception catch (e) {
debugPrint(e.toString());
}
}
void stopListening() {
_operationsSubscription?.cancel();
_operationsSubscription = null;
}
void _loadOperationsSilently() async {
try {
final operations = await _repository.fetchOperations(
companyId: companyId!,
storeId: storeId!,
limit: 10,
offset: 0,
);
if (isClosed) return;
emit(
state.copyWith(
status: DashboardStoreOperationListStatus.success,
operations: operations,
error: null,
),
);
} catch (e) {
if (isClosed) return;
emit(
state.copyWith(
status: DashboardStoreOperationListStatus.failure,
error: e.toString(),
),
);
}
}
@override
Future<void> close() {
stopListening();
return super.close();
}
}

View File

@@ -0,0 +1,30 @@
part of 'dashboard_store_operation_list_cubit.dart';
enum DashboardStoreOperationListStatus { initial, loading, success, failure }
class DashboardStoreOperationListState extends Equatable {
final DashboardStoreOperationListStatus status;
final String? error;
final List<OperationModel> operations;
const DashboardStoreOperationListState({
required this.status,
this.error,
this.operations = const [],
});
@override
List<Object?> get props => [status, error, operations];
DashboardStoreOperationListState copyWith({
DashboardStoreOperationListStatus? status,
String? error,
List<OperationModel>? operations,
}) {
return DashboardStoreOperationListState(
status: status ?? this.status,
error: error,
operations: operations ?? this.operations,
);
}
}

View File

@@ -1,38 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart';
import 'package:flux/features/home/dashboard_store_operation_list/bloc/dashboard_store_operation_list_cubit.dart';
import 'package:go_router/go_router.dart';
class LatestStoreOperationsCard extends StatelessWidget {
const LatestStoreOperationsCard({super.key});
class DashboardStoreOperationListCard extends StatelessWidget {
const DashboardStoreOperationListCard({super.key});
@override
Widget build(BuildContext context) {
final currentStoreId = context.read<SessionCubit>().state.currentStore?.id;
return BlocProvider(
// 1. Creiamo il Bloc e facciamo partire subito la query
create: (context) =>
LatestStoreOperationsBloc()
..add(InitLatestStoreOperationsEvent(currentStoreId ?? '')),
child: BlocListener<SessionCubit, SessionState>(
// 2. MAGIA: Se l'utente cambia negozio dalla barra in alto, riavviamo lo stream!
listenWhen: (previous, current) =>
previous.currentStore?.id != current.currentStore?.id,
listener: (context, state) {
if (state.currentStore?.id != null) {
context.read<LatestStoreOperationsBloc>().add(
InitLatestStoreOperationsEvent(state.currentStore!.id!),
);
}
},
child: _LatestOperationsCardContent(),
),
);
return _LatestOperationsCardContent();
}
}
@@ -91,21 +70,21 @@ class _LatestOperationsCardContent extends StatelessWidget {
Expanded(
child:
BlocBuilder<
LatestStoreOperationsBloc,
LatestStoreOperationsState
DashboardStoreOperationListCubit,
DashboardStoreOperationListState
>(
builder: (context, state) {
if (state.status ==
LatestStoreOperationsStatus.loading ||
DashboardStoreOperationListStatus.loading ||
state.status ==
LatestStoreOperationsStatus.initial) {
DashboardStoreOperationListStatus.initial) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state.status ==
LatestStoreOperationsStatus.failure) {
DashboardStoreOperationListStatus.failure) {
return Center(
child: Text(
"Errore di caricamento",

View File

@@ -0,0 +1,86 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/tickets/data/ticket_repository.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:get_it/get_it.dart';
part 'dashboard_store_ticket_list_state.dart';
class DashboardStoreTicketListCubit
extends Cubit<DashboardStoreTicketListState> {
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
final String? companyId;
final String? storeId;
StreamSubscription<void>? _subscription;
DashboardStoreTicketListCubit({
required this.companyId,
required this.storeId,
}) : super(
const DashboardStoreTicketListState(
status: DashboardStoreTicketListStatus.initial,
),
);
void stopListening() {
_subscription?.cancel();
_subscription = null;
}
void startListening() {
stopListening();
emit(state.copyWith(status: DashboardStoreTicketListStatus.loading));
// Primo caricamento
_loadTicketsSilently();
// Inizio ascolto campanello
try {
_subscription = _repository
.getLatestStoreTicketsStream(storeId: storeId!, limit: 10)
.listen((_) {
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
_loadTicketsSilently();
});
} on Exception catch (e) {
debugPrint(e.toString());
}
}
Future<void> _loadTicketsSilently() async {
try {
final tickets = await _repository.fetchTickets(
companyId: companyId!,
storeId: storeId,
limit: 10,
offset: 0,
);
if (isClosed) return;
emit(
state.copyWith(
status: DashboardStoreTicketListStatus.success,
tickets: tickets,
errorMessage: null,
),
);
} catch (e) {
if (isClosed) return;
emit(
state.copyWith(
status: DashboardStoreTicketListStatus.failure,
errorMessage: e.toString(),
),
);
}
}
@override
Future<void> close() {
stopListening();
return super.close();
}
}

View File

@@ -0,0 +1,30 @@
part of 'dashboard_store_ticket_list_cubit.dart';
enum DashboardStoreTicketListStatus { initial, loading, success, failure }
class DashboardStoreTicketListState extends Equatable {
final DashboardStoreTicketListStatus status;
final List<TicketModel> tickets;
final String? errorMessage;
const DashboardStoreTicketListState({
this.status = DashboardStoreTicketListStatus.initial,
this.tickets = const [],
this.errorMessage,
});
DashboardStoreTicketListState copyWith({
DashboardStoreTicketListStatus? status,
List<TicketModel>? tickets,
String? errorMessage,
}) {
return DashboardStoreTicketListState(
status: status ?? this.status,
tickets: tickets ?? this.tickets,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [status, tickets, errorMessage];
}

View File

@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/home/dashboard_store_ticket_list/blocs/dashboard_store_ticket_list_cubit.dart';
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
import 'package:go_router/go_router.dart';
class DashboardStoreTicketListCard extends StatelessWidget {
const DashboardStoreTicketListCard({super.key});
@override
Widget build(BuildContext context) {
return _DashboardStoreTicketListCardContent();
}
}
class _DashboardStoreTicketListCardContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const color = Colors.blue;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
),
child: InkWell(
onTap: () => context.pushNamed(Routes.tickets),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER DELLA CARD ---
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.design_services_outlined,
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
"Ticket recenti",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
Expanded(
child:
BlocBuilder<
DashboardStoreTicketListCubit,
DashboardStoreTicketListState
>(
builder: (context, state) {
if (state.status ==
DashboardStoreTicketListStatus.loading ||
state.status ==
DashboardStoreTicketListStatus.initial) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state.status ==
DashboardStoreTicketListStatus.failure) {
return Center(
child: Text(
"Errore di caricamento",
style: TextStyle(color: theme.colorScheme.error),
),
);
}
if (state.tickets.isEmpty) {
return Center(
child: Text(
"Nessun ticket recente.",
style: TextStyle(
color: context.secondaryText,
fontStyle: FontStyle.italic,
),
),
);
}
return ListView.separated(
itemCount: state.tickets.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: theme.dividerColor.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final ticket = state.tickets[index];
final statusColor = ticket.ticketStatus.color;
return InkWell(
onTap: () => context.pushNamed(
Routes.ticketForm,
extra: (createdBy: null, ticket: ticket),
pathParameters: {'id': ticket.id!},
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Container(
width: 8,
height:
30, // Un'altezza fissa per farlo comparire!
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(
4,
), // Angoli smussati per stile
),
),
const SizedBox(width: 4),
Expanded(
flex: 5,
child: Text(
ticket.customer?.name ??
'Cliente sconosciuto',
style: TextStyle(
fontWeight: FontWeight.w700,
color: context.primaryText,
),
),
),
Expanded(
flex: 5,
child: Text(
ticket.targetModelName ??
'Modello sconosciuto',
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
"${ticket.createdAt?.day}/${ticket.createdAt?.month}",
style: TextStyle(
color: context.secondaryText,
fontSize: 12,
),
),
],
),
),
);
},
);
},
),
),
],
),
),
),
);
}
}

View File

@@ -1,51 +1,59 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/tasks/data/task_repository.dart';
import 'package:flux/features/tasks/models/task_model.dart';
import 'package:flux/features/tasks/models/task_status.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
part 'dashboard_task_list_state.dart';
class DashboardTaskListCubit extends Cubit<DashboardTaskListState> {
final TaskRepository _repository = GetIt.I.get<TaskRepository>();
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
RealtimeChannel? _taskChannel;
final TasksRepository _repository = GetIt.I.get<TasksRepository>();
final String? staffId;
final String? companyId;
StreamSubscription<void>? _tasksSubscription;
DashboardTaskListCubit() : super(DashboardTaskListState());
DashboardTaskListCubit({required this.staffId, required this.companyId})
: super(const DashboardTaskListState());
void startListening() {
stopListening();
void startListening({required String staffId}) async {
emit(state.copyWith(status: DashboardTaskListStatus.loading));
await _loadTasks(staffId: staffId);
_taskChannel?.unsubscribe();
_taskChannel = _supabase
.channel('public:tasks_staff_$staffId')
.onPostgresChanges(
event: PostgresChangeEvent.all,
schema: 'public',
table: 'tasks',
filter: PostgresChangeFilter(
type: PostgresChangeFilterType.eq,
column: 'staff_id',
value: staffId,
),
callback: (payload) {
_loadTasks(staffId: staffId);
},
);
_taskChannel?.subscribe();
// Primo caricamento
_loadTasksSilently();
// Inizio ascolto campanello
try {
_tasksSubscription = _repository.watchCompanyTasks(companyId!).listen((
_,
) {
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
_loadTasksSilently();
});
} on Exception catch (e) {
debugPrint(e.toString());
}
}
Future<void> _loadTasks({required String staffId}) async {
void stopListening() {
_tasksSubscription?.cancel();
_tasksSubscription = null;
}
Future<void> _loadTasksSilently() async {
try {
final tasks = await _repository.getTasks(
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
companyId: companyId!,
staffId: staffId,
statuses: [TaskStatus.open, TaskStatus.inProgress],
limit: 10,
);
if (isClosed) return;
emit(
state.copyWith(
status: DashboardTaskListStatus.success,
@@ -54,6 +62,7 @@ class DashboardTaskListCubit extends Cubit<DashboardTaskListState> {
),
);
} catch (e) {
if (isClosed) return;
emit(
state.copyWith(
status: DashboardTaskListStatus.failure,
@@ -65,7 +74,7 @@ class DashboardTaskListCubit extends Cubit<DashboardTaskListState> {
@override
Future<void> close() {
_taskChannel?.unsubscribe();
stopListening();
return super.close();
}
}

View File

@@ -1,34 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
import 'package:go_router/go_router.dart';
import 'package:flux/features/tasks/models/task_status.dart';
class DashboardTasksCard extends StatelessWidget {
const DashboardTasksCard({super.key});
class DashboardTaskListCard extends StatelessWidget {
const DashboardTaskListCard({super.key});
@override
Widget build(BuildContext context) {
// Recuperiamo lo staff (o l'utente) loggato
// Adatta il getter in base a come è strutturato il tuo SessionState
final currentStaffId = context
.read<SessionCubit>()
.state
.currentStaffMember
?.id;
if (currentStaffId == null) {
return const SizedBox.shrink(); // Sicurezza se lo stato non è pronto
}
return BlocProvider(
create: (context) =>
DashboardTaskListCubit()..startListening(staffId: currentStaffId),
child: const _DashboardTasksCardContent(),
);
return _DashboardTasksCardContent();
}
}

View File

@@ -1,66 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:get_it/get_it.dart';
part '../../latest_store_operations/bloc/latest_store_operations_events.dart';
part '../../latest_store_operations/bloc/latest_store_operations_state.dart';
class LatestStoreOperationsBloc
extends Bloc<LatestStoreOperationsEvent, LatestStoreOperationsState> {
final _repository = GetIt.I.get<OperationsRepository>();
LatestStoreOperationsBloc()
: super(
const LatestStoreOperationsState(
status: LatestStoreOperationsStatus.initial,
),
) {
on<InitLatestStoreOperationsEvent>((event, emit) async {
emit(state.copyWith(status: LatestStoreOperationsStatus.loading));
try {
// 1. Creiamo uno stream "intermedio" che idrata i dati
final hydratedStream = _repository
.getLatestStoreOperationsStream(storeId: event.storeId, limit: 10)
.asyncMap((List<OperationModel> rawOperations) async {
// Questo gira ad ogni "scatto" dello stream di Supabase
List<OperationModel> fullyHydratedOperations = [];
for (OperationModel operation in rawOperations) {
// Peschiamo i dati completi (incluso il cliente)
OperationModel fullOperation = await _repository
.fetchOperationById(operation.id!);
fullyHydratedOperations.add(fullOperation);
}
// Passiamo la lista completa allo step successivo
return fullyHydratedOperations;
});
// 2. Ora passiamo lo stream idratato all'emit.forEach
await emit.forEach(
hydratedStream, // Usiamo lo stream modificato!
onData: (List<OperationModel> fullyHydratedOperations) {
// Qui ora è tutto sincrono e bellissimo
return state.copyWith(
operations: fullyHydratedOperations,
status: LatestStoreOperationsStatus.success,
);
},
onError: (error, stackTrace) => state.copyWith(
status: LatestStoreOperationsStatus.failure,
error: error.toString(),
),
);
} catch (e) {
emit(
state.copyWith(
status: LatestStoreOperationsStatus.failure,
error: e.toString(),
),
);
}
});
}
}

View File

@@ -1,17 +0,0 @@
part of 'latest_store_operations_bloc.dart';
sealed class LatestStoreOperationsEvent extends Equatable {
const LatestStoreOperationsEvent();
@override
List<Object> get props => [];
}
class InitLatestStoreOperationsEvent extends LatestStoreOperationsEvent {
final String storeId;
const InitLatestStoreOperationsEvent(this.storeId);
@override
List<Object> get props => [storeId];
}

View File

@@ -1,30 +0,0 @@
part of 'latest_store_operations_bloc.dart';
enum LatestStoreOperationsStatus { initial, loading, success, failure }
class LatestStoreOperationsState extends Equatable {
final LatestStoreOperationsStatus status;
final String? error;
final List<OperationModel> operations;
const LatestStoreOperationsState({
required this.status,
this.error,
this.operations = const [],
});
@override
List<Object?> get props => [status, error, operations];
LatestStoreOperationsState copyWith({
LatestStoreOperationsStatus? status,
String? error,
List<OperationModel>? operations,
}) {
return LatestStoreOperationsState(
status: status ?? this.status,
error: error,
operations: operations ?? this.operations,
);
}
}

View File

@@ -1,57 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/tickets/data/ticket_repository.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:get_it/get_it.dart';
part 'latest_store_tickets_events.dart';
part 'latest_store_tickets_state.dart';
class LatestStoreTicketsBloc
extends Bloc<LatestStoreTicketsEvent, LatestStoreTicketsState> {
final _repository = GetIt.I.get<TicketRepository>();
LatestStoreTicketsBloc()
: super(
const LatestStoreTicketsState(status: LatestStoreTicketsStatus.initial),
) {
on<InitLatestStoreTicketsEvent>((event, emit) async {
emit(state.copyWith(status: LatestStoreTicketsStatus.loading));
try {
final hydratedStream = _repository
.getLatestStoreTicketsStream(storeId: event.storeId, limit: 10)
.asyncMap((List<TicketModel> rawTickets) async {
List<TicketModel> fullyHydratedTickets = [];
for (TicketModel ticket in rawTickets) {
TicketModel fullTicket = await _repository.getTicketById(
ticket.id!,
);
fullyHydratedTickets.add(fullTicket);
}
return fullyHydratedTickets;
});
await emit.forEach(
hydratedStream,
onData: (List<TicketModel> fullyHydratedTickets) {
return state.copyWith(
tickets: fullyHydratedTickets,
status: LatestStoreTicketsStatus.success,
);
},
onError: (error, stackTrace) => state.copyWith(
status: LatestStoreTicketsStatus.failure,
error: error.toString(),
),
);
} catch (e) {
emit(
state.copyWith(
status: LatestStoreTicketsStatus.failure,
error: e.toString(),
),
);
}
});
}
}

View File

@@ -1,17 +0,0 @@
part of 'latest_store_tickets_bloc.dart';
abstract class LatestStoreTicketsEvent extends Equatable {
const LatestStoreTicketsEvent();
@override
List<Object> get props => [];
}
class InitLatestStoreTicketsEvent extends LatestStoreTicketsEvent {
final String storeId;
const InitLatestStoreTicketsEvent(this.storeId);
@override
List<Object> get props => [storeId];
}

View File

@@ -1,29 +0,0 @@
part of 'latest_store_tickets_bloc.dart';
enum LatestStoreTicketsStatus { initial, loading, success, failure }
class LatestStoreTicketsState extends Equatable {
final LatestStoreTicketsStatus status;
final String? error;
final List<TicketModel> tickets;
const LatestStoreTicketsState({
required this.status,
this.error,
this.tickets = const [],
});
@override
List<Object?> get props => [status, error, tickets];
LatestStoreTicketsState copyWith({
LatestStoreTicketsStatus? status,
String? error,
List<TicketModel>? tickets,
}) {
return LatestStoreTicketsState(
status: status ?? this.status,
error: error,
tickets: tickets ?? this.tickets,
);
}
}

View File

@@ -1,198 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/home/latest_store_tickets/blocs/latest_store_tickets_bloc.dart';
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
import 'package:go_router/go_router.dart';
class LatestStoreTicketsCard extends StatelessWidget {
const LatestStoreTicketsCard({super.key});
@override
Widget build(BuildContext context) {
final currentStoreId = context.read<SessionCubit>().state.currentStore?.id;
return BlocProvider(
// 1. Creiamo il Bloc e facciamo partire subito la query
create: (context) =>
LatestStoreTicketsBloc()
..add(InitLatestStoreTicketsEvent(currentStoreId ?? '')),
child: BlocListener<SessionCubit, SessionState>(
// 2. MAGIA: Se l'utente cambia negozio dalla barra in alto, riavviamo lo stream!
listenWhen: (previous, current) =>
previous.currentStore?.id != current.currentStore?.id,
listener: (context, state) {
if (state.currentStore?.id != null) {
context.read<LatestStoreTicketsBloc>().add(
InitLatestStoreTicketsEvent(state.currentStore!.id!),
);
}
},
child: _LatestStoreTicketsCardContent(),
),
);
}
}
class _LatestStoreTicketsCardContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const color = Colors.blue;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
),
child: InkWell(
onTap: () => context.pushNamed(Routes.tickets),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER DELLA CARD ---
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.design_services_outlined,
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
"Ticket recenti",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
Expanded(
child: BlocBuilder<LatestStoreTicketsBloc, LatestStoreTicketsState>(
builder: (context, state) {
if (state.status == LatestStoreTicketsStatus.loading ||
state.status == LatestStoreTicketsStatus.initial) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == LatestStoreTicketsStatus.failure) {
return Center(
child: Text(
"Errore di caricamento",
style: TextStyle(color: theme.colorScheme.error),
),
);
}
if (state.tickets.isEmpty) {
return Center(
child: Text(
"Nessun ticket recente.",
style: TextStyle(
color: context.secondaryText,
fontStyle: FontStyle.italic,
),
),
);
}
return ListView.separated(
itemCount: state.tickets.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: theme.dividerColor.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final ticket = state.tickets[index];
final statusColor = ticket.ticketStatus.color;
return InkWell(
onTap: () => context.pushNamed(
Routes.ticketForm,
extra: (createdBy: null, ticket: ticket),
pathParameters: {'id': ticket.id!},
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 8,
height:
30, // Un'altezza fissa per farlo comparire!
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(
4,
), // Angoli smussati per stile
),
),
const SizedBox(width: 4),
Expanded(
flex: 5,
child: Text(
ticket.customer?.name ??
'Cliente sconosciuto',
style: TextStyle(
fontWeight: FontWeight.w700,
color: context.primaryText,
),
),
),
Expanded(
flex: 5,
child: Text(
ticket.targetModelName ??
'Modello sconosciuto',
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
"${ticket.createdAt?.day}/${ticket.createdAt?.month}",
style: TextStyle(
color: context.secondaryText,
fontSize: 12,
),
),
],
),
),
);
},
);
},
),
),
],
),
),
),
);
}
}

View File

@@ -1,25 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/app_router.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/staff_selector_modal.dart';
import 'package:flux/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart';
import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart';
import 'package:flux/features/home/latest_store_tickets/ui/latest_store_tickets_card.dart';
import 'package:flux/features/home/dashboard_note_list/blocs/dashboard_note_list_cubit.dart';
import 'package:flux/features/home/dashboard_store_operation_list/bloc/dashboard_store_operation_list_cubit.dart';
import 'package:flux/features/home/dashboard_store_ticket_list/blocs/dashboard_store_ticket_list_cubit.dart';
import 'package:flux/features/home/dashboard_store_ticket_list/ui/dashboard_store_ticket_list_card.dart';
import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
import 'package:flux/features/home/dashboard_task_list/ui/dashboard_task_list_card.dart';
import 'package:flux/features/home/dashboard_store_operation_list/ui/latest_store_operations_card.dart';
import 'package:flux/features/home/ui/quick_actions_widget.dart';
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/notes/data/notes_repository.dart';
import 'package:flux/features/notes/models/note_model.dart';
import 'package:flux/features/notes/ui/dashboard_notes_widget.dart';
import 'package:flux/features/home/dashboard_note_list/ui/dashboard_note_list_card.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
class HomeScreen extends StatelessWidget {
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
late final AppLifecycleListener _lifecycleListener;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (AppRouter.pendingRoute != null) {
final destination = AppRouter.pendingRoute!;
// ⚠️ Svuota IMMEDIATAMENTE la variabile per evitare loop infiniti se si ruota lo schermo!
AppRouter.pendingRoute = null;
// Spedisci l'utente al task!
context.push(destination);
}
});
// Inizializziamo il sensore del ciclo di vita
_lifecycleListener = AppLifecycleListener(
onPause: () {
// L'utente ha messo l'app in background (es. per rispondere a un messaggio su WhatsApp)
// Chiudiamo i rubinetti per non sprecare risorse e prevenire crash
_stopListeners();
debugPrint('App in background: Stream sospesi.');
},
onResume: () {
// L'utente è tornato sull'app!
// Riappriamo i rubinetti, Supabase ricreerà una connessione fresca
_startListeners();
debugPrint('App in foreground: Stream riattivati.');
},
);
// Facciamo partire gli stream la primissima volta che la schermata si carica
_startListeners();
}
void _stopListeners() {
context.read<DashboardStoreOperationListCubit>().stopListening();
context.read<DashboardTaskListCubit>().stopListening();
context.read<DashboardStoreTicketListCubit>().stopListening();
context.read<DashboardNoteListCubit>().stopListening();
}
void _startListeners() {
context.read<DashboardStoreOperationListCubit>().startListening();
context.read<DashboardTaskListCubit>().startListening();
context.read<DashboardStoreTicketListCubit>().startListening();
context.read<DashboardNoteListCubit>().startListening();
}
@override
void dispose() {
// Pulizia fondamentale
_lifecycleListener.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -67,16 +136,16 @@ class HomeScreen extends StatelessWidget {
childAspectRatio: 1.3,
),
delegate: SliverChildListDelegate([
LatestStoreOperationsCard(),
LatestStoreTicketsCard(),
DashboardStoreOperationListCard(),
DashboardStoreTicketListCard(),
_buildDashboardWidget(
title: context.l10n.homeExpiringContracts,
icon: Icons.assignment_late_outlined,
color: Colors.orange,
context: context,
),
DashboardNotesWidget(),
DashboardTasksCard(),
DashboardNoteListCard(),
DashboardTaskListCard(),
]),
),
),
@@ -93,9 +162,6 @@ class HomeScreen extends StatelessWidget {
}
// ==========================================
// WIDGET BUILDERS
// ==========================================
Widget _buildHeader(BuildContext context, ThemeData theme) {
final user = context.watch<SessionCubit>().state.currentStaffMember;
final currentStore = context.watch<SessionCubit>().state.currentStore;
@@ -232,7 +298,7 @@ class HomeScreen extends StatelessWidget {
QuickActionButton(
icon: Icons.task_alt,
label: context.l10n.commonTask,
color: Colors.teal,
color: Colors.orange,
onTap: () {
context.pushNamed(Routes.taskForm, pathParameters: {'id': 'new'});
},

View File

@@ -0,0 +1,28 @@
import 'package:flux/features/master_data/providers/models/provider_model.dart';
import 'package:flux/features/master_data/providers/models/provider_role.dart';
extension ProviderCompatibility on ProviderModel {
bool supportsOperation(String operationType) {
if (operationType == 'Altro') return true;
switch (operationType) {
case 'AL' || 'MNP':
return roles.contains(ProviderRole.mobile);
case 'NIP' || 'FWA':
return roles.contains(ProviderRole.landline);
case 'UNICA':
return roles.contains(ProviderRole.landline) ||
roles.contains(ProviderRole.mobile);
case 'Energy':
return roles.contains(ProviderRole.energy);
case 'Fin':
return roles.contains(ProviderRole.financing);
case 'Entertainment':
return roles.contains(ProviderRole.entertainment);
case 'TELEPASS':
return roles.contains(ProviderRole.telepass);
default:
return true;
}
}
}

View File

@@ -117,9 +117,9 @@ class StaffCubit extends Cubit<StaffState> {
}
}
Future<void> resetPasswordOrResendInviteLink(String email) async {
Future<void> resetPassword(String email) async {
try {
await _repository.resetPasswordOrResendInviteLink(email);
await _repository.resetPassword(email);
emit(state.copyWith(status: StaffStatus.emailSent, error: null));
} catch (e) {
emit(state.copyWith(status: StaffStatus.error, error: e.toString()));

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/master_data/store/models/store_model.dart';
@@ -10,8 +11,12 @@ class StaffRepository {
// --- ANAGRAFICA PURA ---
// Prende tutto lo staff della Company (per l'Hub Anagrafiche)
Future<List<StaffMemberModel>> getStaffMembers(String companyId) async {
final response = await _supabase
Future<List<StaffMemberModel>> getStaffMembers(
String companyId, {
String? storeId,
}) async {
try {
var filterBuilder = _supabase
.from(Tables.staffMembers)
.select('''
*,
@@ -19,10 +24,25 @@ class StaffRepository {
${Tables.stores}(*)
)
''')
.eq('company_id', companyId)
.order('name', ascending: true);
.eq('company_id', companyId);
return (response as List).map((s) => StaffMemberModel.fromMap(s)).toList();
if (storeId != null) {
filterBuilder = filterBuilder.or(
'store_id.eq.$storeId,store_id.is.null',
);
}
var transformBuilder = filterBuilder.order('name', ascending: true);
final response = await transformBuilder;
return (response as List)
.map((s) => StaffMemberModel.fromMap(s))
.toList();
} on Exception catch (e) {
debugPrint('Errore nel recupero della lista di staff: $e');
throw Exception('Errore nel recupero della lista di staff: $e');
}
}
Future<StaffMemberModel?> getStaffMemberById(String staffId) async {
@@ -84,12 +104,16 @@ class StaffRepository {
}
}
Future<void> resetPasswordOrResendInviteLink(String email) async {
Future<void> resetPassword(String email) async {
try {
await _supabase.auth.resetPasswordForEmail(
email,
redirectTo: resetPasswordUrl,
final response = await Supabase.instance.client.functions.invoke(
'reset_password',
body: {'email': email.trim()},
);
if (response.status != 200) {
throw Exception(response.data['error'] ?? "Errore sconosciuto");
}
} catch (e) {
throw Exception("Errore nell'invio del link: $e");
}
@@ -129,10 +153,10 @@ class StaffRepository {
// Assegna un membro a un negozio
Future<void> assignStaffToStore(String staffId, String storeId) async {
await _supabase.from(Tables.staffInStores).insert({
await _supabase.from(Tables.staffInStores).upsert({
'staff_member_id': staffId,
'store_id': storeId,
});
}, onConflict: 'staff_member_id,store_id'); // Evita duplicati
}
// Rimuove l'assegnazione

View File

@@ -90,7 +90,7 @@ class _StaffScreenState extends State<StaffScreen> {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: list.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
separatorBuilder: (_, _) => const SizedBox(height: 6),
itemBuilder: (context, index) {
return _buildStaffCard(list[index]);
},
@@ -190,16 +190,23 @@ class _StaffScreenState extends State<StaffScreen> {
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'invite_reset') {
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
member.email!,
if (!member.hasJoined) {
context.read<StaffCubit>().inviteStaffMember(
member: member,
selectedStoreIds: member.assignedStores
.map((s) => s.id!)
.toList(),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Operazione richiesta, controlla l\'email!',
'Invito reinviato, controlla l\'email!',
),
),
);
} else {
context.read<StaffCubit>().resetPassword(member.email!);
}
}
},
itemBuilder: (context) => [

View File

@@ -17,14 +17,18 @@ class StoreCubit extends Cubit<StoreState> {
StoreCubit() : super(const StoreState(stores: []));
Future<void> createStore(final StoreModel store) async {
Future<void> saveStore(final StoreModel store) async {
emit(state.copyWith(status: StoreStatus.loading));
try {
await _repository.createStore(store);
emit(state.copyWith(status: StoreStatus.success));
final savedStore = await _repository.saveStore(store);
emit(state.copyWith(status: StoreStatus.success, savedStore: savedStore));
} catch (e) {
emit(
state.copyWith(status: StoreStatus.failure, errorMessage: e.toString()),
state.copyWith(
status: StoreStatus.failure,
errorMessage: e.toString(),
savedStore: null,
),
);
}
}
@@ -70,6 +74,7 @@ class StoreCubit extends Cubit<StoreState> {
state.copyWith(
status: StoreStatus.failure,
errorMessage: "Errore nel salvataggio dei provider: $e",
savedStore: null,
),
);
}
@@ -90,6 +95,7 @@ class StoreCubit extends Cubit<StoreState> {
state.copyWith(
status: StoreStatus.failure,
errorMessage: "Errore nel salvataggio dello staff: $e",
savedStore: null,
),
);
}
@@ -110,6 +116,7 @@ class StoreCubit extends Cubit<StoreState> {
state.copyWith(
status: StoreStatus.failure,
errorMessage: "Errore nell'associazione: $e",
savedStore: null,
),
);
}
@@ -130,6 +137,7 @@ class StoreCubit extends Cubit<StoreState> {
state.copyWith(
status: StoreStatus.failure,
errorMessage: "Errore nella rimozione: $e",
savedStore: null,
),
);
}
@@ -142,7 +150,11 @@ class StoreCubit extends Cubit<StoreState> {
loadStores();
} catch (e) {
emit(
state.copyWith(status: StoreStatus.failure, errorMessage: e.toString()),
state.copyWith(
status: StoreStatus.failure,
errorMessage: e.toString(),
savedStore: null,
),
);
}
}
@@ -157,6 +169,7 @@ class StoreCubit extends Cubit<StoreState> {
state.copyWith(
status: StoreStatus.failure,
errorMessage: "Errore nella rimozione: $e",
savedStore: null,
),
);
}

View File

@@ -7,6 +7,8 @@ class StoreState extends Equatable {
final StoreModel? store;
final String? errorMessage;
final List<StoreModel> stores;
final StoreModel?
savedStore; // Per tenere traccia del negozio appena salvato (utile per aggiornare la sessione)
final Map<String, List<StaffMemberModel>> staffByStore;
const StoreState({
@@ -14,6 +16,7 @@ class StoreState extends Equatable {
this.store,
this.errorMessage,
required this.stores,
this.savedStore,
this.staffByStore = const {},
});
@@ -22,6 +25,7 @@ class StoreState extends Equatable {
StoreModel? store,
String? errorMessage,
List<StoreModel>? stores,
StoreModel? savedStore,
Map<String, List<StaffMemberModel>>? staffByStore,
}) {
return StoreState(
@@ -29,6 +33,7 @@ class StoreState extends Equatable {
store: store ?? this.store,
errorMessage: errorMessage ?? this.errorMessage,
stores: stores ?? this.stores,
savedStore: savedStore ?? this.savedStore,
staffByStore: staffByStore ?? this.staffByStore,
);
}
@@ -39,6 +44,7 @@ class StoreState extends Equatable {
store,
errorMessage,
stores,
savedStore,
staffByStore,
];
}

View File

@@ -9,7 +9,7 @@ class StoreRepository {
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
/// Crea un nuovo negozio associato alla compagnia dell'utente
Future<void> createStore(StoreModel store) async {
/* Future<void> createStore(StoreModel store) async {
try {
await _supabase.from(Tables.stores).insert(store.toMap());
} on PostgrestException catch (e) {
@@ -18,7 +18,7 @@ class StoreRepository {
} catch (e) {
throw 'Errore imprevisto durante la creazione del negozio: $e';
}
}
} */
Future<StoreModel> saveStore(StoreModel store) async {
try {

View File

@@ -16,6 +16,7 @@ class StoreModel extends Equatable {
final List<ProviderModel> associatedProviders; // Provider associati
final List<StaffMemberModel>
associatedStaffMembers; // Membri dello staff associati
final String? defaultProviderId; // ID del provider di default (opzionale)
const StoreModel({
this.id,
@@ -30,6 +31,7 @@ class StoreModel extends Equatable {
required this.province,
this.associatedProviders = const [],
this.associatedStaffMembers = const [],
this.defaultProviderId,
});
// Fondamentale per Equatable: definisce quali proprietà determinano l'uguaglianza
@@ -47,6 +49,7 @@ class StoreModel extends Equatable {
province,
associatedProviders,
associatedStaffMembers,
defaultProviderId,
];
// Il mitico copyWith per creare nuove istanze modificando solo ciò che serve
@@ -63,6 +66,7 @@ class StoreModel extends Equatable {
String? province,
List<ProviderModel>? associatedProviders,
List<StaffMemberModel>? associatedStaffMembers,
String? Function()? defaultProviderId,
}) {
return StoreModel(
id: id ?? this.id,
@@ -78,6 +82,9 @@ class StoreModel extends Equatable {
associatedProviders: associatedProviders ?? this.associatedProviders,
associatedStaffMembers:
associatedStaffMembers ?? this.associatedStaffMembers,
defaultProviderId: defaultProviderId != null
? defaultProviderId()
: this.defaultProviderId,
);
}
@@ -131,6 +138,7 @@ class StoreModel extends Equatable {
province: map['province'],
associatedProviders: providers,
associatedStaffMembers: staffMembers,
defaultProviderId: map['default_provider_id'] as String?,
);
}
@@ -147,6 +155,7 @@ class StoreModel extends Equatable {
'zip_code': zipCode,
'city': city,
'province': province,
'default_provider_id': defaultProviderId,
};
}
}

View File

@@ -76,7 +76,7 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
province: _provinciaController.text.trim().toUpperCase(),
);
context.read<StoreCubit>().createStore(store);
context.read<StoreCubit>().saveStore(store);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
import 'package:flux/features/master_data/store/models/store_model.dart';
@@ -19,6 +20,8 @@ class _StoreFormState extends State<StoreForm> {
final capController = TextEditingController();
final comuneController = TextEditingController();
final provinciaController = TextEditingController();
String?
_selectedDefaultProviderId; // Per tenere traccia del provider di default selezionato
@override
void initState() {
@@ -29,12 +32,51 @@ class _StoreFormState extends State<StoreForm> {
capController.text = widget.store!.zipCode;
comuneController.text = widget.store!.city;
provinciaController.text = widget.store!.province;
_selectedDefaultProviderId = widget.store!.defaultProviderId;
}
context.read<ProviderListCubit>().loadProviders(
widget.store!.id!,
); // Carichiamo i gestori per la dropdown
}
@override
Widget build(BuildContext context) {
return Container(
return BlocListener<StoreCubit, StoreState>(
listener: (context, state) {
if (state.status == StoreStatus.success) {
// 1. Diciamo alla schermata di ricaricare la lista generale dei negozi (se serve)
context.read<StoreCubit>().loadStores();
// 🥷 2. IL TOCCO FINALE: Aggiorniamo la sessione globale se stiamo modificando il negozio attivo!
if (state.savedStore != null) {
context.read<SessionCubit>().updateCurrentStoreLocally(
state.savedStore!,
);
}
// 3. Chiudiamo il form
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Negozio aggiornato con successo!',
style: TextStyle(color: Colors.white),
),
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
}
if (state.status == StoreStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
backgroundColor: Colors.red,
),
);
}
},
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
@@ -51,8 +93,13 @@ class _StoreFormState extends State<StoreForm> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.store == null ? "Nuovo Punto Vendita" : "Modifica Negozio",
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
widget.store == null
? "Nuovo Punto Vendita"
: "Modifica Negozio",
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
@@ -109,6 +156,10 @@ class _StoreFormState extends State<StoreForm> {
),
],
),
const SizedBox(height: 16),
// --- GESTORI ---
_defaultProviderDropdown(),
const SizedBox(height: 32),
@@ -137,12 +188,11 @@ class _StoreFormState extends State<StoreForm> {
isActive: widget.store?.isActive ?? true,
isPaid: widget.store?.isPaid ?? false,
paymentExpiration: widget.store?.paymentExpiration,
defaultProviderId: _selectedDefaultProviderId,
);
// Chiamata al Bloc per il salvataggio
context.read<StoreCubit>().createStore(storeData);
Navigator.pop(context);
context.read<StoreCubit>().saveStore(storeData);
},
child: Text(
widget.store == null ? "CREA NEGOZIO" : "AGGIORNA DATI",
@@ -152,6 +202,71 @@ class _StoreFormState extends State<StoreForm> {
],
),
),
),
);
}
Widget _defaultProviderDropdown() {
return BlocBuilder<ProviderListCubit, ProviderListState>(
builder: (context, state) {
if (state.status == ProviderListStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
final activeProviders = state.providers
.where((p) => p.isActive)
.toList();
// 🥷 SCENARIO ONBOARDING: La lista dei gestori è vuota
if (activeProviders.isEmpty) {
return TextFormField(
enabled: false, // Disabilitiamo il campo
decoration: InputDecoration(
labelText: 'Gestore di Default',
hintText: 'Configura prima i gestori nell\'hub anagrafiche',
hintStyle: TextStyle(color: Colors.grey[500], fontSize: 13),
prefixIcon: const Icon(Icons.star_border, color: Colors.grey),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey[300]!),
),
fillColor: Colors.grey[50],
filled: true,
),
);
}
// SCENARIO STANDARD: Ci sono gestori censiti, mostriamo la dropdown
return DropdownButtonFormField<String?>(
initialValue: _selectedDefaultProviderId,
decoration: InputDecoration(
labelText: 'Gestore di Default (Opzionale)',
hintText: 'Seleziona se questo è un negozio monomarca',
prefixIcon: const Icon(Icons.star_border, color: Colors.amber),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text(
'Nessun gestore (Multi-brand)',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
...activeProviders.map((p) {
return DropdownMenuItem<String?>(
value: p.id,
child: Text(p.name),
);
}),
],
onChanged: (val) {
setState(() {
_selectedDefaultProviderId = val;
});
},
);
},
);
}
}

View File

@@ -1,80 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/notes/data/notes_repository.dart';
import 'package:flux/features/notes/models/note_model.dart';
import 'package:get_it/get_it.dart';
part 'notes_event.dart';
part 'notes_state.dart';
class NotesBloc extends Bloc<NotesEvent, NotesState> {
final NotesRepository _repository = GetIt.I.get<NotesRepository>();
final String _companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
final String _currentStaffId = GetIt.I
.get<SessionCubit>()
.state
.currentStaffMember!
.id!;
NotesBloc() : super(const NotesState()) {
on<SubscribeToNotesRequested>(_onSubscribeToNotesRequested);
on<NoteSavedRequested>(_onNoteSavedRequested);
on<NoteDeletedRequested>(_onNoteDeletedRequested);
// Facciamo partire l'ascolto in tempo reale al boot del BLoC
add(SubscribeToNotesRequested());
}
Future<void> _onSubscribeToNotesRequested(
SubscribeToNotesRequested event,
Emitter<NotesState> emit,
) async {
emit(state.copyWith(status: NotesStatus.loading));
// Usiamo l'emit.forEach sullo stream pulito del repository
await emit.forEach<List<NoteModel>>(
_repository.notesStream(
companyId: _companyId,
currentStaffId: _currentStaffId,
),
onData: (notesList) {
return state.copyWith(status: NotesStatus.success, notes: notesList);
},
onError: (error, stackTrace) {
return state.copyWith(
status: NotesStatus.failure,
errorMessage: 'Errore nello stream realtime: $error',
);
},
);
}
Future<void> _onNoteSavedRequested(
NoteSavedRequested event,
Emitter<NotesState> emit,
) async {
try {
await _repository.saveNote(event.note);
// Non serve fare l'emit! Ci pensa lo stream a far rimbalzare i dati aggiornati
} catch (e) {
emit(
state.copyWith(status: NotesStatus.failure, errorMessage: e.toString()),
);
}
}
Future<void> _onNoteDeletedRequested(
NoteDeletedRequested event,
Emitter<NotesState> emit,
) async {
try {
await _repository.deleteNote(event.noteId);
// Anche qui, lo stream rileva la cancellazione in automatico
} catch (e) {
emit(
state.copyWith(status: NotesStatus.failure, errorMessage: e.toString()),
);
}
}
}

View File

@@ -0,0 +1,94 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/notes/data/notes_repository.dart';
import 'package:flux/features/notes/models/note_model.dart';
import 'package:get_it/get_it.dart';
part 'notes_state.dart';
class NotesCubit extends Cubit<NotesState> {
final NotesRepository _repository = GetIt.I.get<NotesRepository>();
String? get companyId => GetIt.I.get<SessionCubit>().state.company?.id;
String? get staffId =>
GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
StreamSubscription<void>? _subscription;
NotesCubit() : super(NotesState(status: NotesStatus.initial));
void stopListening() {
_subscription?.cancel();
_subscription = null;
}
void startListening() {
stopListening();
emit(state.copyWith(status: NotesStatus.loading));
// Primo caricamento
_loadNotesSilently();
// Inizio ascolto campanello
try {
_subscription = _repository
.notesStream(companyId: companyId!, currentStaffId: staffId!)
.listen((_) {
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
_loadNotesSilently();
});
} on Exception catch (e) {
debugPrint(e.toString());
}
}
Future<void> _loadNotesSilently() async {
try {
final notes = await _repository.getNotes();
emit(
state.copyWith(
status: NotesStatus.success,
notes: notes,
errorMessage: null,
),
);
} catch (e) {
emit(
state.copyWith(status: NotesStatus.failure, errorMessage: e.toString()),
);
}
}
Future<void> saveNote(NoteModel note) async {
try {
await _repository.saveNote(note);
// Non serve fare l'emit! Ci pensa lo stream a far rimbalzare i dati aggiornati
} catch (e) {
emit(
state.copyWith(status: NotesStatus.failure, errorMessage: e.toString()),
);
}
}
Future<void> deleteNote(String noteId) async {
try {
await _repository.deleteNote(noteId);
// Non serve fare l'emit
} catch (e) {
emit(
state.copyWith(status: NotesStatus.failure, errorMessage: e.toString()),
);
}
}
@override
Future<void> close() {
stopListening();
return super.close();
}
}

View File

@@ -1,17 +0,0 @@
part of 'notes_bloc.dart';
sealed class NotesEvent {}
/// Fa partire lo stream e gestisce sia il caricamento iniziale che il realtime
class SubscribeToNotesRequested extends NotesEvent {}
class NoteDeletedRequested extends NotesEvent {
final String noteId;
NoteDeletedRequested(this.noteId);
}
/// Salva o aggiorna una nota
class NoteSavedRequested extends NotesEvent {
final NoteModel note;
NoteSavedRequested(this.note);
}

View File

@@ -1,8 +1,8 @@
part of 'notes_bloc.dart';
part of 'notes_cubit.dart';
enum NotesStatus { initial, loading, success, failure }
class NotesState {
class NotesState extends Equatable {
final NotesStatus status;
final List<NoteModel> notes;
final String? errorMessage;
@@ -26,14 +26,5 @@ class NotesState {
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is NotesState &&
other.status == status &&
listEquals(other.notes, notes) &&
other.errorMessage == errorMessage;
}
@override
int get hashCode => status.hashCode ^ notes.hashCode ^ errorMessage.hashCode;
List<Object?> get props => [status, notes, errorMessage];
}

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/features/notes/models/note_model.dart';
@@ -130,13 +131,26 @@ class NotesRepository {
await _supabase.from('note_collaborators').delete().eq('note_id', noteId);
// 3. RE-INSERIMENTO DELLA LISTA AGGIORNATA
// Se ci sono collaboratori da inserire, li prepariamo in blocco (Bulk Insert)
if (note.collaboratorIds.isNotEmpty) {
final collaboratorsToInsert = note.collaboratorIds
.map((staffId) => {'note_id': noteId, 'staff_id': staffId})
.map(
(staffId) => {
'note_id': noteId,
'staff_id': staffId,
'company_id': note.companyId, // Aggiunto questo!
},
)
.toList();
await _supabase.from('note_collaborators').insert(collaboratorsToInsert);
// Consiglio da pro: avvolgi l'insert in un try-catch per stampare l'errore esatto a console
try {
await _supabase
.from(Tables.noteCollaborators)
.insert(collaboratorsToInsert);
} catch (e) {
debugPrint('Errore inserimento collaboratori: $e');
throw Exception('Impossibile aggiungere i collaboratori alla nota.');
}
}
// Restituiamo l'id alla UI (fondamentale per la nostra logica Ninja di creazione)

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/utils/debouncer.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/notes/blocs/notes_bloc.dart';
import 'package:flux/features/notes/blocs/notes_cubit.dart';
import 'package:flux/features/notes/models/note_model.dart';
class NoteFormScreen extends StatefulWidget {
@@ -20,7 +20,7 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
NoteModel get _note => widget.note;
late TextEditingController _titleController;
late TextEditingController _contentController;
late final NotesBloc _notesBloc;
late final NotesCubit _notesCubit;
late String _selectedColor;
late bool _isPinned;
late bool _isSharedAll;
@@ -43,7 +43,7 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
super.initState();
_titleController = TextEditingController(text: widget.note.title ?? '');
_contentController = TextEditingController(text: widget.note.content ?? '');
_notesBloc = context.read<NotesBloc>();
_notesCubit = context.read<NotesCubit>();
_selectedColor = widget.note.color;
_isPinned = widget.note.isPinned;
_isSharedAll = widget.note.isSharedAll;
@@ -90,7 +90,7 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
);
// Spariamo l'evento al Bloc, che salverà silente sul DB tramite Repository
_notesBloc.add(NoteSavedRequested(updatedNote));
_notesCubit.saveNote(updatedNote);
}
/// Se l'utente esce e la nota è totalmente vuota, la eliminiamo dal DB "al secchio"
@@ -103,12 +103,12 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
// Assumiamo che se non ha scritto testo ed è appena stata creata, sia vuota.
if (titleEmpty && contentEmpty) {
// Notifichiamo anche il Bloc dell'avvenuta cancellazione così pulisce lo stato locale
_notesBloc.add(NoteDeletedRequested(noteId));
_notesCubit.deleteNote(noteId);
}
}
void _deleteNote() {
_notesBloc.add(NoteDeletedRequested(widget.note.id!));
_notesCubit.deleteNote(widget.note.id!);
Navigator.pop(context);
}
@@ -134,7 +134,9 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
builder: (context) {
return StatefulBuilder(
builder: (context, setModalState) {
return Column(
return Material(
color: Colors.transparent,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
@@ -201,6 +203,7 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
),
),
],
),
);
},
);
@@ -403,7 +406,9 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
const SizedBox(height: 12),
// --- CONDIVISIONE ---
SwitchListTile(
Material(
color: Colors.transparent,
child: SwitchListTile(
title: const Text(
'Condividi con tutti',
style: TextStyle(
@@ -422,6 +427,7 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
_triggerAutoSave();
},
),
),
if (!_isSharedAll) ...[
const SizedBox(height: 16),

View File

@@ -2,16 +2,54 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/features/notes/blocs/notes_bloc.dart';
import 'package:flux/features/notes/blocs/notes_cubit.dart';
import 'package:flux/features/notes/data/notes_repository.dart';
import 'package:go_router/go_router.dart';
import 'package:get_it/get_it.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/notes/models/note_model.dart';
class NotesListScreen extends StatelessWidget {
class NotesListScreen extends StatefulWidget {
const NotesListScreen({super.key});
@override
State<NotesListScreen> createState() => _NotesListScreenState();
}
class _NotesListScreenState extends State<NotesListScreen> {
late final AppLifecycleListener _lifecycleListener;
@override
void initState() {
super.initState();
// Inizializziamo il sensore del ciclo di vita
_lifecycleListener = AppLifecycleListener(
onPause: () {
// L'utente ha messo l'app in background (es. per rispondere a un messaggio su WhatsApp)
// Chiudiamo i rubinetti per non sprecare risorse e prevenire crash
context.read<NotesCubit>().stopListening();
debugPrint('App in background: Stream sospesi.');
},
onResume: () {
// L'utente è tornato sull'app!
// Riappriamo i rubinetti, Supabase ricreerà una connessione fresca
context.read<NotesCubit>().startListening();
debugPrint('App in foreground: Stream riattivati.');
},
);
// Facciamo partire gli stream la primissima volta che la schermata si carica
context.read<NotesCubit>().startListening();
}
@override
void dispose() {
// Pulizia fondamentale
_lifecycleListener.dispose();
super.dispose();
}
/// Logica Ninja: Crea la nota vuota, prende l'ID, e apre il form
Future<void> _createNewNoteAndNavigate(BuildContext context) async {
final sessionState = context.read<SessionCubit>().state;
@@ -73,7 +111,7 @@ class NotesListScreen extends StatelessWidget {
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
),
body: BlocBuilder<NotesBloc, NotesState>(
body: BlocBuilder<NotesCubit, NotesState>(
builder: (context, state) {
if (state.status == NotesStatus.loading && state.notes.isEmpty) {
return const Center(child: CircularProgressIndicator());

View File

@@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/master_data/providers/models/provider_model.dart';
import 'package:flux/features/master_data/providers/models/provider_model_extensions.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:flux/features/operations/models/operation_model.dart';
@@ -278,27 +280,126 @@ class OperationFormCubit extends Cubit<OperationFormState> {
// --- UTILS ---
void setTypeWithSmartDefault(String type) {
void updateOperationType(
String newType, {
required List<ProviderModel> allProviders,
String? defaultProviderId,
}) {
// 1. Aggiorniamo il tipo nel modello in canna
// (Presumo tu abbia un metodo copyWith o simile)
final updatedOp = state.operation.copyWith(type: newType, subtype: '');
// 2. Prepariamoci ad auto-selezionare il provider
String? newProviderId = updatedOp.providerId;
String? newProviderName = updatedOp.providerDisplayName;
// 3. LA LOGICA DI DEFAULT
if (defaultProviderId != null) {
// Troviamo il provider di default nella lista
final defaultProvider = allProviders
.where((p) => p.id == defaultProviderId)
.firstOrNull;
if (defaultProvider != null) {
// Usiamo l'extension appena creata!
if (defaultProvider.supportsOperation(newType)) {
newProviderId = defaultProvider.id;
newProviderName = defaultProvider.name;
} else {
// Se cambi tipo (es. da Mobile a Luce) e il default non lo supporta, sbianchiamo
newProviderId = null;
newProviderName = null;
}
}
}
// Emettiamo il nuovo stato
emit(
state.copyWith(
operation: updatedOp.copyWith(
providerId: newProviderId,
providerDisplayName: newProviderName,
),
),
);
}
void setTypeWithSmartDefaults({
required String newType,
required List<ProviderModel> allProviders,
String? defaultProviderId,
}) {
final currentOp = state.operation;
// -----------------------------------------
// 1. SMART DATES: Calcolo Scadenze Default
// -----------------------------------------
DateTime? defaultDate;
final now = DateTime.now();
if (type == 'Energy') {
if (newType == 'Energy') {
defaultDate = DateTime(now.year, now.month + 24, now.day);
}
if (type == 'Fin') {
if (newType == 'Fin') {
defaultDate = DateTime(now.year, now.month + 30, now.day);
}
if (type == 'Entertainment') {
if (newType == 'Entertainment') {
defaultDate = DateTime(now.year, now.month + 12, now.day);
}
updateFields(
type: type,
expirationDate: defaultDate,
clearProvider: true,
clearSubtype: true,
clearModel: true,
clearQuantity: true,
// -----------------------------------------
// 2. SMART PROVIDER: Filtro e Auto-Selezione
// -----------------------------------------
String? newProviderId = currentOp.providerId;
String? newProviderName = currentOp.providerDisplayName;
// A) Il provider attuale è ancora compatibile col nuovo tipo scelto?
if (newProviderId != null && newProviderId.isNotEmpty) {
final currentProvider = allProviders
.where((p) => p.id == newProviderId)
.firstOrNull;
if (currentProvider == null ||
!currentProvider.supportsOperation(newType)) {
// Non è più compatibile (es. da TIM fisso passo a Energy). Lo sbianchiamo!
newProviderId = null;
newProviderName = null;
}
}
// B) Se non c'è un provider selezionato, proviamo ad auto-inserire quello di default del negozio
if ((newProviderId == null || newProviderId.isEmpty) &&
defaultProviderId != null) {
final defaultProvider = allProviders
.where((p) => p.id == defaultProviderId)
.firstOrNull;
// Controlliamo che il default del negozio supporti questa specifica operazione
if (defaultProvider != null &&
defaultProvider.supportsOperation(newType)) {
newProviderId = defaultProvider.id;
newProviderName = defaultProvider.name;
}
}
// -----------------------------------------
// 3. EMISSIONE DELLO STATO PULITO
// -----------------------------------------
emit(
state.copyWith(
operation: currentOp.copyWith(
type: newType,
subtype:
'', // Resettiamo il sottotipo per evitare incongruenze (es. passo da Luce a DAZN)
expirationDate:
defaultDate, // Impostiamo la scadenza di default se calcolata
providerId: newProviderId,
providerDisplayName: newProviderName,
modelId: null,
modelDisplayName: null,
),
),
);
}
}

View File

@@ -38,6 +38,9 @@ class OperationsRepository {
// --- RECUPERO PAGINATO CON FILTRI E JOIN ---
Future<List<OperationModel>> fetchOperations({
required String companyId,
String? storeId,
String? staffId,
String? providerId,
required int offset,
int limit = 50,
String? searchTerm,
@@ -64,6 +67,18 @@ class OperationsRepository {
.lte('created_at', dateRange.end.toIso8601String());
}
if (storeId != null) {
query = query.or('store_id.eq.$storeId,store_id.is.null');
}
if (staffId != null) {
query = query.or('staff_id.eq.$staffId,staff_id.is.null');
}
if (providerId != null) {
query = query.or('provider_id.eq.$providerId,provider_id.is.null');
}
if (searchTerm != null && searchTerm.isNotEmpty) {
// Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or(
@@ -83,7 +98,7 @@ class OperationsRepository {
}
}
Stream<List<OperationModel>> getLatestStoreOperationsStream({
Stream<List<OperationModel>> watchStoreOperations({
required String storeId,
required int limit,
}) {

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/widgets/shared_forms/attachments_section.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart';
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/core/widgets/shared_forms/customer_section.dart';
@@ -398,8 +400,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
labelText: 'Riferimento (es. Telefono, Targa...)',
prefixIcon: Icon(Icons.tag),
),
validator: (v) =>
v == null || v.isEmpty ? 'Inserisci un riferimento' : null,
),
],
);
@@ -527,8 +527,24 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
selected: state.operation.type == type,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().setTypeWithSmartDefault(
type,
// 1. Recuperiamo i provider caricati in memoria
final allProviders = context
.read<ProviderListCubit>()
.state
.providers;
// 2. Recuperiamo il provider di default del negozio dalla sessione
final defaultProviderId = context
.read<SessionCubit>()
.state
.currentStore
?.defaultProviderId;
// 3. Spariamo tutto nel metodo "tuttofare"
context.read<OperationFormCubit>().setTypeWithSmartDefaults(
newType: type,
allProviders: allProviders,
defaultProviderId: defaultProviderId,
);
}
},

View File

@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/widgets/shared_forms/model_section.dart';
import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart';
import 'package:flux/features/master_data/providers/models/provider_model.dart';
import 'package:flux/features/master_data/providers/models/provider_role.dart';
import 'package:flux/features/master_data/providers/models/provider_model_extensions.dart';
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
@@ -23,34 +22,6 @@ class OperationDetailsSection extends StatelessWidget {
required this.durationQuickPicks,
});
bool _doesProviderMatchOperationType(
ProviderModel provider,
String operationType,
) {
if (operationType == 'Altro') return true;
// Controlliamo che il fornitore abbia il ruolo specifico nel suo array
switch (operationType) {
case 'AL' || 'MNP':
return provider.roles.contains(ProviderRole.mobile);
case 'NIP' || 'FWA':
return provider.roles.contains(ProviderRole.landline);
case 'UNICA':
return provider.roles.contains(ProviderRole.landline) ||
provider.roles.contains(ProviderRole.mobile);
case 'Energy':
return provider.roles.contains(ProviderRole.energy);
case 'Fin':
return provider.roles.contains(ProviderRole.financing);
case 'Entertainment':
return provider.roles.contains(ProviderRole.entertainment);
case 'TELEPASS':
return provider.roles.contains(ProviderRole.telepass);
default:
return true;
}
}
void _showProviderModal(BuildContext context, String operationType) {
final OperationFormCubit cubit = context.read<OperationFormCubit>();
showModalBottomSheet(
@@ -92,14 +63,9 @@ class OperationDetailsSection extends StatelessWidget {
return const Center(child: CircularProgressIndicator());
}
// Prendiamo i provider e li filtriamo per ruolo e per stato attivo
// 🥷 IL TOCCO DEL NINJA: Filtriamo usando direttamente l'Extension sul Modello!
final filteredProviders = state.providers.where((p) {
final isMatch = _doesProviderMatchOperationType(
p,
operationType,
);
return isMatch &&
p.isActive; // Mostriamo solo quelli attivi!
return p.supportsOperation(operationType) && p.isActive;
}).toList();
if (filteredProviders.isEmpty) {

View File

@@ -0,0 +1,108 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/settings/data/settings_repository.dart'; // O dove hai messo i metodi del DB
import 'package:flux/features/tasks/models/reminder_default_model.dart';
import 'package:get_it/get_it.dart';
part 'reminder_defaults_state.dart';
class ReminderDefaultsCubit extends Cubit<ReminderDefaultsState> {
final SettingsRepository _repository = GetIt.I.get<SettingsRepository>();
final SessionCubit _sessionCubit = GetIt.I.get<SessionCubit>();
ReminderDefaultsCubit() : super(const ReminderDefaultsState());
String get _companyId => _sessionCubit.state.company!.id!;
String get _staffId => _sessionCubit.state.currentStaffMember!.id!;
Future<void> loadReminders() async {
emit(state.copyWith(status: ReminderDefaultsStatus.loading));
try {
final reminders = await _repository.getMyReminderDefaults(
companyId: _companyId,
staffId: _staffId,
);
emit(
state.copyWith(
status: ReminderDefaultsStatus.success,
reminders: reminders,
),
);
} catch (e) {
emit(
state.copyWith(
status: ReminderDefaultsStatus.failure,
errorMessage: e.toString(),
),
);
}
}
Future<void> addReminder({
required int minutesBefore,
required String channel,
}) async {
emit(state.copyWith(status: ReminderDefaultsStatus.loading));
try {
final newReminder = ReminderDefaultModel(
companyId: _companyId,
staffId: _staffId,
minutesBefore: minutesBefore,
channel: channel,
);
final savedReminder = await _repository.addReminderDefault(newReminder);
// Aggiungiamo alla lista locale e ordiniamo per minuti
final updatedList = List<ReminderDefaultModel>.from(state.reminders)
..add(savedReminder);
updatedList.sort((a, b) => a.minutesBefore.compareTo(b.minutesBefore));
emit(
state.copyWith(
status: ReminderDefaultsStatus.success,
reminders: updatedList,
),
);
} catch (e) {
emit(
state.copyWith(
status: ReminderDefaultsStatus.failure,
errorMessage: e.toString(),
),
);
// Ricarichiamo per sicurezza lo stato precedente
loadReminders();
}
}
Future<void> deleteReminder(String reminderId) async {
// Salviamo la lista vecchia nel caso fallisca la cancellazione
final oldList = List<ReminderDefaultModel>.from(state.reminders);
// Aggiornamento ottimistico (rimuoviamo subito dalla UI)
final optimisticList = state.reminders
.where((r) => r.id != reminderId)
.toList();
emit(
state.copyWith(
status: ReminderDefaultsStatus.success,
reminders: optimisticList,
),
);
try {
await _repository.deleteReminderDefault(reminderId);
} catch (e) {
// Rollback se il DB fallisce
emit(
state.copyWith(
status: ReminderDefaultsStatus.failure,
errorMessage: e.toString(),
reminders: oldList,
),
);
}
}
}

View File

@@ -0,0 +1,33 @@
part of 'reminder_defaults_cubit.dart';
enum ReminderDefaultsStatus { initial, loading, success, failure }
class ReminderDefaultsState extends Equatable {
final ReminderDefaultsStatus status;
final List<ReminderDefaultModel> reminders;
final String? errorMessage;
const ReminderDefaultsState({
this.status = ReminderDefaultsStatus.initial,
this.reminders = const [],
this.errorMessage,
});
ReminderDefaultsState copyWith({
ReminderDefaultsStatus? status,
List<ReminderDefaultModel>? reminders,
String? errorMessage,
}) {
return ReminderDefaultsState(
status: status ?? this.status,
reminders: reminders ?? this.reminders,
// Se passiamo un nuovo status di successo o loading, puliamo l'errore
errorMessage:
errorMessage ??
(status != ReminderDefaultsStatus.failure ? null : this.errorMessage),
);
}
@override
List<Object?> get props => [status, reminders, errorMessage];
}

View File

@@ -0,0 +1,63 @@
import 'package:flux/features/tasks/models/reminder_default_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class SettingsRepository {
final _supabase = GetIt.I.get<SupabaseClient>();
// --- PREFERENZE REMINDER ---
/// Legge i default dell'utente corrente
Future<List<ReminderDefaultModel>> getMyReminderDefaults({
required String companyId,
required String staffId,
}) async {
try {
final response = await _supabase
.from('staff_task_reminder_defaults')
.select()
.eq('company_id', companyId)
.eq('staff_id', staffId)
.order('minutes_before', ascending: true);
return (response as List)
.map((map) => ReminderDefaultModel.fromMap(map))
.toList();
} catch (e) {
throw Exception('Errore nel caricamento delle preferenze notifiche: $e');
}
}
/// Aggiunge una nuova regola (es. Push 15 min prima)
Future<ReminderDefaultModel> addReminderDefault(
ReminderDefaultModel reminder,
) async {
try {
final response = await _supabase
.from('staff_task_reminder_defaults')
.insert(reminder.toMap())
.select()
.single();
return ReminderDefaultModel.fromMap(response);
} catch (e) {
// Catturiamo l'errore UNIQUE se l'utente prova ad aggiungere due volte la stessa identica regola
if (e is PostgrestException && e.code == '23505') {
throw Exception('Hai già impostato questo identico promemoria.');
}
throw Exception('Errore salvataggio promemoria: $e');
}
}
/// Elimina una regola
Future<void> deleteReminderDefault(String reminderId) async {
try {
await _supabase
.from('staff_task_reminder_defaults')
.delete()
.eq('id', reminderId);
} catch (e) {
throw Exception('Errore durante l\'eliminazione: $e');
}
}
}

View File

@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/settings/blocs/reminder_defaults_cubit.dart';
class ReminderSettingsScreen extends StatefulWidget {
const ReminderSettingsScreen({super.key});
@override
State<ReminderSettingsScreen> createState() => _ReminderSettingsScreenState();
}
class _ReminderSettingsScreenState extends State<ReminderSettingsScreen> {
@override
void initState() {
super.initState();
// Carichiamo i dati all'avvio
context.read<ReminderDefaultsCubit>().loadReminders();
}
void _showAddReminderBottomSheet(BuildContext context) {
final cubit = context.read<ReminderDefaultsCubit>();
// Valori preselezionati
int selectedMinutes = 15;
String selectedChannel = 'push';
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (bottomSheetContext) {
return StatefulBuilder(
builder: (context, setModalState) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Nuova Regola di Avviso',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
// --- SELEZIONE TEMPO ---
DropdownButtonFormField<int>(
decoration: const InputDecoration(
labelText: 'Quando vuoi essere avvisato?',
border: OutlineInputBorder(),
),
initialValue: selectedMinutes,
items: const [
DropdownMenuItem(
value: 5,
child: Text('5 minuti prima'),
),
DropdownMenuItem(
value: 15,
child: Text('15 minuti prima'),
),
DropdownMenuItem(value: 60, child: Text('1 ora prima')),
DropdownMenuItem(
value: 120,
child: Text('2 ore prima'),
),
DropdownMenuItem(
value: 1440,
child: Text('1 giorno prima'),
),
],
onChanged: (val) {
if (val != null) {
setModalState(() => selectedMinutes = val);
}
},
),
const SizedBox(height: 16),
// --- SELEZIONE CANALE ---
DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: 'Come vuoi essere avvisato?',
border: OutlineInputBorder(),
),
initialValue: selectedChannel,
items: const [
DropdownMenuItem(
value: 'push',
child: Row(
children: [
Icon(
Icons.notifications_active,
size: 20,
color: Colors.orange,
),
SizedBox(width: 8),
Text('Notifica App (Push)'),
],
),
),
DropdownMenuItem(
value: 'email',
child: Row(
children: [
Icon(Icons.email, size: 20, color: Colors.blue),
SizedBox(width: 8),
Text('Email'),
],
),
),
],
onChanged: (val) {
if (val != null) {
setModalState(() => selectedChannel = val);
}
},
),
const SizedBox(height: 32),
// --- SALVATAGGIO ---
FilledButton(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
onPressed: () {
cubit.addReminder(
minutesBefore: selectedMinutes,
channel: selectedChannel,
);
Navigator.pop(context);
},
child: const Text('Aggiungi Regola'),
),
],
),
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Preferenze Promemoria')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddReminderBottomSheet(context),
icon: const Icon(Icons.add_alert),
label: const Text('Aggiungi'),
backgroundColor: Colors.orange,
),
body: BlocConsumer<ReminderDefaultsCubit, ReminderDefaultsState>(
listener: (context, state) {
if (state.status == ReminderDefaultsStatus.failure &&
state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage!),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
},
builder: (context, state) {
if (state.status == ReminderDefaultsStatus.loading &&
state.reminders.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.reminders.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.notifications_off_outlined,
size: 64,
color: Theme.of(context).dividerColor,
),
const SizedBox(height: 16),
const Text(
'Nessun promemoria predefinito.',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Aggiungi una regola per ricevere in automatico le notifiche quando ti viene assegnato un task.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
],
),
),
);
}
return ListView.builder(
padding: const EdgeInsets.only(
top: 16,
bottom: 80,
left: 16,
right: 16,
),
itemCount: state.reminders.length,
itemBuilder: (context, index) {
final reminder = state.reminders[index];
final isPush = reminder.channel == 'push';
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.5),
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
leading: CircleAvatar(
backgroundColor: isPush
? Colors.orange.withValues(alpha: 0.1)
: Colors.blue.withValues(alpha: 0.1),
child: Icon(
isPush ? Icons.notifications_active : Icons.email,
color: isPush ? Colors.orange : Colors.blue,
),
),
title: Text(
reminder.friendlyTime, // Usiamo l'helper del Model!
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
isPush ? 'Tramite Notifica App' : 'Tramite Email',
),
trailing: IconButton(
icon: const Icon(
Icons.delete_outline,
color: Colors.redAccent,
),
onPressed: () {
context.read<ReminderDefaultsCubit>().deleteReminder(
reminder.id!,
);
},
),
),
);
},
);
},
),
);
}
}

View File

@@ -18,6 +18,15 @@ class SettingsScreen extends StatelessWidget {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_settingsSection('Utente', [
_settingsTile(
title: 'Impostazioni Promemoria',
icon: Icons.notifications,
subtitle: 'Notifiche predefinite',
context: context,
onTap: () => context.pushNamed(Routes.reminderSettings),
),
]),
_settingsSection('Azienda', [
_settingsTile(
title: 'Impostazioni Azienda',
@@ -83,6 +92,7 @@ class SettingsScreen extends StatelessWidget {
onTap: () => context.pushNamed(Routes.themeSettings),
),
]),
const SizedBox(height: 24),
TextButton.icon(
onPressed: () => context.read<SessionCubit>().signOut(),

View File

@@ -1,209 +1,255 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/master_data/staff/data/staff_repository.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/settings/data/settings_repository.dart';
import 'package:flux/features/tasks/data/task_repository.dart';
import 'package:flux/features/tasks/models/task_model.dart';
import 'package:flux/features/tasks/models/task_status.dart';
import 'package:flux/features/tasks/models/task_reminder_config.dart';
import 'package:get_it/get_it.dart';
part 'task_form_state.dart';
class TaskFormCubit extends Cubit<TaskFormState> {
final TaskRepository _taskRepository = GetIt.I.get<TaskRepository>();
final List<StaffMemberModel> _globalStaff;
final String currentCompanyId = GetIt.I<SessionCubit>().state.company!.id!;
final String? currentStoreId = GetIt.I<SessionCubit>().state.currentStore?.id;
final TasksRepository _repository = GetIt.I.get<TasksRepository>();
final SettingsRepository _settingsRepository = GetIt.I
.get<SettingsRepository>();
final _staffRepository = GetIt.I.get<StaffRepository>();
final SessionCubit _sessionCubit = GetIt.I.get<SessionCubit>();
final List<StaffMemberModel>? _preloadedStaff;
TaskFormCubit({
required List<StaffMemberModel> globalStaff,
TaskModel? initialTask, // Arriva dalla navigazione interna (extra)
String? initialTaskId, // Arriva dal Deep Link (parametro URL)
}) : _globalStaff = globalStaff,
String? initialTaskId, // <-- RIPRISTINATO PER DEEP LINK
TaskModel? existingTask,
List<StaffMemberModel>? allStaff,
}) : _preloadedStaff = allStaff,
super(const TaskFormState()) {
_initForm(initialTask, initialTaskId);
// Avviamo l'inizializzazione centralizzata (gestisce sia mem, sia deep link, sia nuovo)
initForm(initialTaskId: initialTaskId, existingTask: existingTask);
}
Future<void> _initForm(TaskModel? task, String? taskId) async {
// 1. Mettiamo subito il form in caricamento
String get _companyId => _sessionCubit.state.company!.id!;
String get _currentUserId => _sessionCubit.state.currentStaffMember!.id!;
String? get _currentStoreId => _sessionCubit.state.currentStore?.id;
// --- ARMED INITIALIZATION (Nuovo, Esistente o Deep Link) ---
Future<void> initForm({
String? initialTaskId,
TaskModel? existingTask,
}) async {
emit(state.copyWith(status: TaskFormStatus.loading));
TaskModel? taskToLoad = task;
// 2. SCENARIO DEEP LINK: Non abbiamo l'oggetto, ma abbiamo un ID valido
if (taskToLoad == null && taskId != null && taskId != 'new') {
try {
taskToLoad = await _taskRepository.getTaskById(taskId);
TaskModel? task = existingTask;
// 1. Se arriviamo da Deep Link col solo ID, lo scarichiamo dal DB
if (initialTaskId != null && task == null) {
task = await _repository.fetchTaskById(initialTaskId);
}
if (task != null) {
// CASO: TASK ESISTENTE (Modifica o Deep Link pronto)
emit(
state.copyWith(
id: task.id,
title: task.title,
description: task.description,
dueDate: task.dueDate,
isGlobal: task.isGlobal, // Sfrutta il tuo getter storeId == null
selectedStaffIds: task.assignedToIds,
),
);
await _loadExistingTaskReminders(task.id!);
} else {
// CASO: NUOVO TASK
await _initializeNewTaskReminders();
}
// 2. Carichiamo e raggruppiamo il personale (Global o Store)
await _loadAndGroupStaff();
// Mandiamo lo status a 'initial' così il FormScreen sincronizza i controller di testo!
emit(state.copyWith(status: TaskFormStatus.initial));
} catch (e) {
emit(
state.copyWith(
status: TaskFormStatus.failure,
errorMessage: 'Impossibile caricare il task dal link: $e',
errorMessage: e.toString(),
),
);
return; // Ci fermiamo qui
}
}
// 3. Popoliamo lo stato con i dati (sia che arrivino dall'extra, sia dal DB, sia nulli)
final isGlobalMode = taskToLoad != null
? taskToLoad.storeId == null
: currentStoreId == null;
final existingStaffIds =
taskToLoad?.assignedToStaff.map((s) => s.id!).toList() ??
taskToLoad?.assignedToIds ??
[];
// --- LOGICA GESTIONE STAFF (GLOBAL STAFF / STORE STAFF) ---
Future<void> _loadAndGroupStaff() async {
final List<StaffMemberModel> staffList;
emit(
state.copyWith(
id: taskToLoad?.id,
title: taskToLoad?.title ?? '',
description: taskToLoad?.description ?? '',
dueDate: taskToLoad?.dueDate,
taskStatus: taskToLoad?.status ?? TaskStatus.open,
isGlobal: isGlobalMode,
selectedStaffIds: existingStaffIds,
status: TaskFormStatus.initial, // Caricamento finito, form pronto!
),
// SE C'È LO STAFF PASCIUTO DALL'APP USA QUELLO, ALTRIMENTI CHIAMA IL REPO
if (_preloadedStaff != null && _preloadedStaff.isNotEmpty) {
staffList = _preloadedStaff;
} else {
staffList = await _staffRepository.getStaffMembers(_companyId);
}
final Map<String, List<StaffMemberModel>> grouped = {};
for (var staff in staffList) {
if (!state.isGlobal) {
final belongsToCurrentStore = staff.assignedStores.any(
(store) => store.id == _currentStoreId,
);
_updateStaffScope(isGlobalMode);
if (!belongsToCurrentStore) continue;
}
// --- 2. SWITCH SCOPE E RAGGRUPPAMENTO ---
void toggleGlobalScope(bool isGlobal) {
emit(
state.copyWith(
isGlobal: isGlobal,
selectedStaffIds: [], // Resettiamo la selezione se si cambia scope
),
);
_updateStaffScope(isGlobal);
}
void _updateStaffScope(bool isGlobal) {
// 1. Filtriamo in memoria: cerchiamo nell'array degli ID!
final filteredStaff = isGlobal
? _globalStaff
: _globalStaff
.where((s) => s.assignedStoreIds.contains(currentStoreId))
.toList();
// 2. Raggruppamento M2M (Ciclo manuale)
final Map<String, List<StaffMemberModel>> groupedStaff = {};
for (final staff in filteredStaff) {
// Se non ha nessun negozio assegnato, finisce in Direzione
if (staff.assignedStores.isEmpty) {
groupedStaff.putIfAbsent('Direzione / HQ', () => []).add(staff);
grouped.putIfAbsent('Direzione / Senza Sede', () => []).add(staff);
} else {
// Se ha più negozi, clona la sua presenza in ogni gruppo!
for (final store in staff.assignedStores) {
for (var store in staff.assignedStores) {
if (!state.isGlobal && store.id != _currentStoreId) continue;
final storeName = store.name;
groupedStaff.putIfAbsent(storeName, () => []).add(staff);
grouped.putIfAbsent(storeName, () => []).add(staff);
}
}
}
// 3. Emettiamo il nuovo stato all'istante
emit(state.copyWith(groupedAvailableStaff: grouped));
}
// Se l'utente switcha su "Globale Aziendale", ricarichiamo lo staff di conseguenza
void toggleGlobalScope(bool g) async {
emit(state.copyWith(isGlobal: g, status: TaskFormStatus.loading));
await _loadAndGroupStaff();
emit(
state.copyWith(status: TaskFormStatus.initial),
); // Ri-notifichiamo la UI
}
// --- INIT REMINDER ---
Future<void> _initializeNewTaskReminders() async {
try {
final defaults = await _settingsRepository.getMyReminderDefaults(
companyId: _companyId,
staffId: _currentUserId,
);
final initialReminders = defaults
.map(
(d) => TaskReminderConfig(
minutesBefore: d.minutesBefore,
channel: d.channel,
),
)
.toList();
emit(state.copyWith(reminders: initialReminders));
} catch (e) {
emit(
state.copyWith(
status: TaskFormStatus.initial,
groupedAvailableStaff: groupedStaff,
reminders: const [
TaskReminderConfig(minutesBefore: 15, channel: 'push'),
],
),
);
}
// --- 3. SELEZIONE AVANZATA (SINGOLA E PER NEGOZIO) ---
void toggleStaffSelection(String staffId) {
final currentList = List<String>.from(state.selectedStaffIds);
if (currentList.contains(staffId)) {
currentList.remove(staffId);
} else {
currentList.add(staffId);
}
emit(state.copyWith(selectedStaffIds: currentList));
Future<void> _loadExistingTaskReminders(String taskId) async {
try {
final existingConfigs = await _repository.fetchPersonalReminders(
taskId: taskId,
staffId: _currentUserId,
);
emit(state.copyWith(reminders: existingConfigs));
} catch (e) {
debugPrint('Errore caricamento reminder: $e');
}
}
// --- AGGIORNAMENTO CAMPI ---
void updateTitle(String t) => emit(state.copyWith(title: t));
void updateDescription(String d) => emit(state.copyWith(description: d));
void updateDueDate(DateTime? d) => emit(state.copyWith(dueDate: d));
void toggleStaffSelection(String staffId) {
final updated = List<String>.from(state.selectedStaffIds);
updated.contains(staffId) ? updated.remove(staffId) : updated.add(staffId);
emit(state.copyWith(selectedStaffIds: updated));
}
void toggleStoreSelection(String storeName, bool selectAll) {
// Recupera tutti i membri di quel gruppo
final staffInStore = state.groupedAvailableStaff[storeName] ?? [];
final idsInStore = staffInStore.map((s) => s.id!).toList();
final currentSelection = Set<String>.from(state.selectedStaffIds);
final updated = List<String>.from(state.selectedStaffIds);
final storeStaff = state.groupedAvailableStaff[storeName] ?? [];
for (var staff in storeStaff) {
if (staff.id == null) continue;
if (selectAll) {
currentSelection.addAll(idsInStore);
if (!updated.contains(staff.id)) updated.add(staff.id!);
} else {
currentSelection.removeAll(idsInStore);
updated.remove(staff.id);
}
}
emit(state.copyWith(selectedStaffIds: updated));
}
emit(state.copyWith(selectedStaffIds: currentSelection.toList()));
}
// --- 4. AGGIORNAMENTO CAMPI STANDARD ---
void updateTitle(String title) => emit(state.copyWith(title: title));
void updateDescription(String desc) =>
emit(state.copyWith(description: desc));
void updateDueDate(DateTime? date) =>
emit(state.copyWith(dueDate: date, clearDueDate: date == null));
void updateLinkedTicket(String? ticketId) =>
emit(state.copyWith(linkedTicketId: ticketId));
// --- 5. LOGICA DEI REMINDER FINTI ---
void addMockReminder(String type, int minutes) {
final currentReminders = List<TaskReminder>.from(state.reminders);
currentReminders.add(TaskReminder(type: type, minutesBefore: minutes));
emit(state.copyWith(reminders: currentReminders));
}
void removeMockReminder(int index) {
final currentReminders = List<TaskReminder>.from(state.reminders);
currentReminders.removeAt(index);
emit(state.copyWith(reminders: currentReminders));
}
// --- 6. SALVATAGGIO FINALE ---
Future<void> saveTask({required String currentUserId}) async {
if (!state.isFormValid) return;
emit(state.copyWith(status: TaskFormStatus.submitting));
try {
final taskToSave = TaskModel(
id: state.id,
companyId: currentCompanyId,
storeId: state.isGlobal
? null
: currentStoreId, // La vera discriminante Globale/Store!
createdById: state.id == null
? currentUserId
: null, // Lo settiamo solo alla creazione
title: state.title.trim(),
description: state.description.trim().isEmpty
? null
: state.description.trim(),
dueDate: state.dueDate,
status: state.taskStatus,
assignedToIds: state
.selectedStaffIds, // L'array che andrà a popolare la tabella di giunzione
// --- AZIONI REMINDER ---
void addReminderRule(int minutesBefore, String channel) {
final updated = List<TaskReminderConfig>.from(state.reminders);
final newConfig = TaskReminderConfig(
minutesBefore: minutesBefore,
channel: channel,
);
if (state.id == null) {
await _taskRepository.createTask(taskToSave);
} else {
await _taskRepository.updateTask(taskToSave);
if (!updated.contains(newConfig)) {
updated.add(newConfig);
updated.sort((a, b) => a.minutesBefore.compareTo(b.minutesBefore));
emit(state.copyWith(reminders: updated));
}
}
void removeReminderRule(int index) {
final updated = List<TaskReminderConfig>.from(state.reminders)
..removeAt(index);
emit(state.copyWith(reminders: updated));
}
// --- SALVATAGGIO ---
Future<void> saveTask() async {
if (!state.isFormValid) return;
emit(state.copyWith(status: TaskFormStatus.submitting));
final taskToSave = TaskModel(
id: state.id,
companyId: _companyId,
createdById: _currentUserId,
title: state.title.trim(),
description: state.description.trim(),
dueDate: state.dueDate,
storeId: state.isGlobal
? null
: _currentStoreId, // Gestione nativa basata sulla tua logica
assignedToIds: state.selectedStaffIds,
);
try {
if (state.id == null) {
await _repository.createTask(
task: taskToSave,
assignedStaffIds: state.selectedStaffIds,
currentUserId: _currentUserId,
currentUserCustomReminders: state.reminders,
);
} else {
await _repository.updateTask(
task: taskToSave,
assignedStaffIds: state.selectedStaffIds,
currentUserId: _currentUserId,
currentUserCustomReminders: state.reminders,
);
}
emit(state.copyWith(status: TaskFormStatus.success));
} catch (e) {
emit(
state.copyWith(
status: TaskFormStatus.failure,
errorMessage: 'Errore durante il salvataggio: $e',
errorMessage: e.toString(),
),
);
}

View File

@@ -2,108 +2,72 @@ part of 'task_form_cubit.dart';
enum TaskFormStatus { initial, loading, submitting, success, failure }
/// Placeholder finto per i futuri reminder (pg_cron)
class TaskReminder extends Equatable {
final String type; // es. 'email', 'push'
final int minutesBefore;
const TaskReminder({required this.type, required this.minutesBefore});
@override
List<Object?> get props => [type, minutesBefore];
}
class TaskFormState extends Equatable {
final String? id;
final TaskFormStatus status;
final String? id; // Null se stiamo creando un nuovo task
final String title;
final String description;
final DateTime? dueDate;
final TaskStatus taskStatus;
// --- SCOPING & ASSIGNMENTS ---
final bool isGlobal;
final List<String> selectedStaffIds;
final List<StaffMemberModel> availableStaff;
final Map<String, List<StaffMemberModel>> groupedAvailableStaff;
// --- FUTURI ANCORAGGI ---
final List<TaskReminder> reminders;
final String? linkedTicketId;
final String? linkedEmailId;
final List<TaskReminderConfig> reminders;
final Map<String, List<StaffMemberModel>>
groupedAvailableStaff; // <-- RIPRISTINATO
final String? errorMessage;
const TaskFormState({
this.status = TaskFormStatus.initial,
this.id,
this.status = TaskFormStatus.initial,
this.title = '',
this.description = '',
this.dueDate,
this.taskStatus = TaskStatus.open,
this.isGlobal = false,
this.selectedStaffIds = const [],
this.availableStaff = const [],
this.groupedAvailableStaff = const {},
this.reminders = const [],
this.linkedTicketId,
this.linkedEmailId,
this.groupedAvailableStaff = const {},
this.errorMessage,
});
// MAGIA: Il form è valido solo se c'è un titolo e, opzionalmente, altre regole.
bool get isFormValid => title.trim().isNotEmpty;
TaskFormState copyWith({
TaskFormStatus? status,
String? id,
TaskFormStatus? status,
String? title,
String? description,
DateTime? dueDate,
bool clearDueDate = false, // Trucco Ninja per rimettere a null una data!
TaskStatus? taskStatus,
bool? isGlobal,
List<String>? selectedStaffIds,
List<StaffMemberModel>? availableStaff,
List<TaskReminderConfig>? reminders,
Map<String, List<StaffMemberModel>>? groupedAvailableStaff,
List<TaskReminder>? reminders,
String? linkedTicketId,
String? linkedEmailId,
String? errorMessage,
}) {
return TaskFormState(
status: status ?? this.status,
id: id ?? this.id,
status: status ?? this.status,
title: title ?? this.title,
description: description ?? this.description,
dueDate: clearDueDate ? null : (dueDate ?? this.dueDate),
taskStatus: taskStatus ?? this.taskStatus,
dueDate: dueDate ?? this.dueDate,
isGlobal: isGlobal ?? this.isGlobal,
selectedStaffIds: selectedStaffIds ?? this.selectedStaffIds,
availableStaff: availableStaff ?? this.availableStaff,
reminders: reminders ?? this.reminders,
groupedAvailableStaff:
groupedAvailableStaff ?? this.groupedAvailableStaff,
reminders: reminders ?? this.reminders,
linkedTicketId: linkedTicketId ?? this.linkedTicketId,
linkedEmailId: linkedEmailId ?? this.linkedEmailId,
errorMessage:
errorMessage, // Se copyWith è chiamato senza errore, lo pulisce in automatico
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => [
status,
id,
status,
title,
description,
dueDate,
taskStatus,
isGlobal,
selectedStaffIds,
availableStaff,
groupedAvailableStaff,
reminders,
linkedTicketId,
linkedEmailId,
groupedAvailableStaff,
errorMessage,
];
}

View File

@@ -8,7 +8,7 @@ import 'package:get_it/get_it.dart';
part 'task_list_state.dart';
class TaskListCubit extends Cubit<TaskListState> {
final TaskRepository _repository = GetIt.I.get<TaskRepository>();
final TasksRepository _repository = GetIt.I.get<TasksRepository>();
final String currentCompanyId;
final String? currentStoreId;

View File

@@ -1,51 +1,47 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/features/tasks/models/task_reminder_config.dart';
import 'package:flux/features/tasks/models/task_status.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
// Sostituisci con i percorsi corretti di FLUX
import 'package:flux/features/tasks/models/task_model.dart';
class TaskRepository {
final SupabaseClient _supabase;
class TasksRepository {
final _supabase = GetIt.I.get<SupabaseClient>();
TaskRepository({SupabaseClient? supabase})
: _supabase = supabase ?? Supabase.instance.client;
// =========================================================================
// LETTURA REMINDER (Per il form in edit)
// =========================================================================
Future<List<TaskReminderConfig>> fetchPersonalReminders({
required String taskId,
required String staffId,
}) async {
try {
final response = await _supabase
.from('task_reminders')
.select()
.eq('task_id', taskId)
.eq('staff_id', staffId)
.eq(
'is_forced',
false,
); // Peschiamo SOLO quelli modificabili dall'utente
// --- LOGICA REAL-TIME (Il Campanello) ---
Stream<void> watchCompanyTasks(String companyId) {
// Usiamo un broadcast nel caso più bloc volessero ascoltarlo in futuro
final controller = StreamController<void>.broadcast();
final channel = _supabase.channel('public:tasks_company_$companyId');
channel
.onPostgresChanges(
event: PostgresChangeEvent.all,
schema: 'public',
table: Tables.tasks,
filter: PostgresChangeFilter(
type: PostgresChangeFilterType.eq,
column: 'company_id',
value: companyId,
return (response as List)
.map(
(r) => TaskReminderConfig(
minutesBefore: r['minutes_before'],
channel: r['channel'],
),
callback: (payload) {
if (!controller.isClosed) {
controller.add(
null,
); // Suoniamo il campanello! Nessun dato, solo il "ding"
}
},
)
.subscribe();
// Quando il Cubit smette di ascoltare, puliamo il canale Supabase in automatico
controller.onCancel = () {
channel.unsubscribe();
controller.close();
};
return controller.stream;
.toList();
} catch (e) {
debugPrint('Errore fetch personal reminders: $e');
throw Exception('Errore fetch personal reminders: $e');
}
}
// --- RECUPERO DEI TASK FILTRATI ---
@@ -103,76 +99,7 @@ class TaskRepository {
}
}
// --- 2. CREAZIONE DEL TASK ---
Future<TaskModel> createTask(TaskModel task) async {
try {
final taskData = task.toMap();
// Rimuoviamo l'array prima di inviare i dati alla tabella principale,
// la "Strada C" impone che la verità assoluta derivi dalla tabella di giunzione!
taskData.remove('assigned_to_ids');
// 1. Inseriamo il record base nella tabella tasks
final response = await _supabase
.from(Tables.tasks)
.insert(taskData)
.select()
.single();
final newTask = TaskModel.fromMap(response);
// 2. Se l'utente ha assegnato il task a qualcuno, popoliamo la giunzione
if (task.assignedToIds.isNotEmpty && newTask.id != null) {
await _syncAssignments(newTask.id!, task.assignedToIds);
// 3. Ricarichiamo il task appena creato per ottenere l'array e il JOIN aggiornati dal trigger!
return await getTaskById(newTask.id!);
}
return newTask;
} catch (e) {
throw Exception('Errore nella creazione del task: $e');
}
}
// --- 3. AGGIORNAMENTO DEL TASK ---
Future<TaskModel> updateTask(TaskModel task) async {
if (task.id == null) {
throw Exception('ID Task mancante. Impossibile aggiornare.');
}
try {
final taskData = task.toMap();
taskData.remove(
'assigned_to_ids',
); // Sempre via l'array dal body principale
// 1. Aggiorniamo i dati base del task (titolo, stato, scadenza, ecc.)
await _supabase.from(Tables.tasks).update(taskData).eq('id', task.id!);
// 2. Sincronizziamo in modo distruttivo gli assegnatari nella tabella di giunzione
await _syncAssignments(task.id!, task.assignedToIds);
// 3. Ritorniamo il modello fresco di DB
return await getTaskById(task.id!);
} catch (e) {
throw Exception('Errore nell\'aggiornamento del task: $e');
}
}
// --- 4. ELIMINAZIONE DEL TASK ---
Future<void> deleteTask(String taskId) async {
try {
// Eliminando il task, se la Foreign Key su Supabase ha "ON DELETE CASCADE",
// le righe nella tabella di giunzione si distruggeranno da sole in automatico!
await _supabase.from(Tables.tasks).delete().eq('id', taskId);
} catch (e) {
throw Exception('Errore nell\'eliminazione del task: $e');
}
}
// --- HELPER: RECUPERO SINGOLO TASK ---
Future<TaskModel> getTaskById(String taskId) async {
Future<TaskModel?> fetchTaskById(String taskId) async {
try {
final response = await _supabase
.from(Tables.tasks)
@@ -184,28 +111,265 @@ class TaskRepository {
''')
.eq('id', taskId)
.single();
return TaskModel.fromMap(response);
} catch (e) {
throw Exception('Errore nel recupero del singolo task: $e');
debugPrint('Errore fetch task by id: $e');
throw Exception('Errore fetch task by id: $e');
}
}
// --- HELPER: SINCRONIZZAZIONE DELLA TABELLA DI GIUNZIONE ---
Future<void> _syncAssignments(String taskId, List<String> staffIds) async {
// TECNICA "WIPE & REPLACE":
// Invece di capire chi è stato aggiunto e chi tolto, eliminiamo tutti i record
// di questo task e li reinseriamo. È fulmineo ed esclude a priori bug di disallineamento.
// =========================================================================
// REALTIME STREAM (La sentinella per la bacheca)
// =========================================================================
Stream<List<TaskModel>> watchCompanyTasks(String companyId) {
return _supabase
.from('tasks')
.stream(primaryKey: ['id'])
.eq('company_id', companyId)
.map((listOfMaps) {
return listOfMaps.map((map) => TaskModel.fromMap(map)).toList();
});
}
// 1. Facciamo piazza pulita
await _supabase.from(Tables.taskAssignments).delete().eq('task_id', taskId);
// =========================================================================
// CREAZIONE (Insert)
// =========================================================================
Future<void> createTask({
required TaskModel task,
required List<String> assignedStaffIds,
required String currentUserId,
required List<TaskReminderConfig> currentUserCustomReminders,
TaskReminderConfig? managerForcedOverride,
}) async {
try {
// 1. Inseriamo il Task principale per farci generare l'ID dal DB
final taskResponse = await _supabase
.from('tasks')
.insert(task.toMap()) // Assicurati che toMap() escluda l'id se è null
.select('id')
.single();
// 2. Inseriamo le nuove assegnazioni (se ce ne sono)
if (staffIds.isNotEmpty) {
final List<Map<String, dynamic>> assignments = staffIds
.map((staffId) => {'task_id': taskId, 'staff_id': staffId})
final String taskId = taskResponse['id'];
// 2. Inseriamo le Assegnazioni (tabella task_assignments)
if (assignedStaffIds.isNotEmpty) {
final assignmentsToInsert = assignedStaffIds
.map(
(staffId) => {
'task_id': taskId,
'staff_id': staffId,
'company_id': task.companyId,
},
)
.toList();
await _supabase.from(Tables.taskAssignments).insert(assignments);
await _supabase.from('task_assignments').insert(assignmentsToInsert);
}
// Se non c'è data di scadenza, niente promemoria a tempo
if (task.dueDate == null || assignedStaffIds.isEmpty) return;
// 3. Setup Reminder: Peschiamo i default degli ALTRI dipendenti coinvolti
final otherStaffIds = assignedStaffIds
.where((id) => id != currentUserId)
.toList();
List<dynamic> otherDefaults = [];
if (otherStaffIds.isNotEmpty) {
otherDefaults = await _supabase
.from('staff_task_reminder_defaults')
.select()
.inFilter('staff_id', otherStaffIds);
}
// 4. Creiamo la lista Bulk Insert per la tabella task_reminders
List<Map<String, dynamic>> remindersToInsert = [];
for (var staffId in assignedStaffIds) {
// A) Se è l'utente loggato -> usa i reminder configurati nel form
if (staffId == currentUserId) {
for (var config in currentUserCustomReminders) {
final triggerAt = task.dueDate!.subtract(
Duration(minutes: config.minutesBefore),
);
if (triggerAt.isAfter(DateTime.now())) {
remindersToInsert.add(
_buildReminderRow(
task,
taskId,
staffId,
config,
triggerAt,
false,
),
);
}
}
}
// B) Se è un collega -> eredita i suoi default preimpostati
else {
final staffRules = otherDefaults.where(
(row) => row['staff_id'] == staffId,
);
for (var rule in staffRules) {
final config = TaskReminderConfig(
minutesBefore: rule['minutes_before'],
channel: rule['channel'],
);
final triggerAt = task.dueDate!.subtract(
Duration(minutes: config.minutesBefore),
);
if (triggerAt.isAfter(DateTime.now())) {
remindersToInsert.add(
_buildReminderRow(
task,
taskId,
staffId,
config,
triggerAt,
false,
),
);
}
}
}
// C) Override forzato del manager (per tutti)
if (managerForcedOverride != null) {
final triggerAt = task.dueDate!.subtract(
Duration(minutes: managerForcedOverride.minutesBefore),
);
if (triggerAt.isAfter(DateTime.now())) {
remindersToInsert.add(
_buildReminderRow(
task,
taskId,
staffId,
managerForcedOverride,
triggerAt,
true,
),
);
}
}
}
// 5. Inserimento massivo finale
if (remindersToInsert.isNotEmpty) {
await _supabase.from('task_reminders').insert(remindersToInsert);
}
} catch (e) {
throw Exception('Errore durante la creazione del task: $e');
}
}
// =========================================================================
// AGGIORNAMENTO (Update)
// =========================================================================
Future<void> updateTask({
required TaskModel task,
required List<String> assignedStaffIds,
required String currentUserId,
required List<TaskReminderConfig> currentUserCustomReminders,
}) async {
try {
final taskId = task.id!;
// 1. Aggiornamento dati Task Base
await _supabase
.from('tasks')
.update({
'title': task.title,
'description': task.description,
'due_date': task.dueDate?.toIso8601String(),
'store_id': task.storeId,
'updated_at': DateTime.now().toIso8601String(),
})
.eq('id', taskId);
// 2. Aggiornamento Assegnazioni: eliminiamo le vecchie, inseriamo le nuove
await _supabase.from('task_assignments').delete().eq('task_id', taskId);
if (assignedStaffIds.isNotEmpty) {
final assignmentsToInsert = assignedStaffIds
.map(
(staffId) => {
'task_id': taskId,
'staff_id': staffId,
'company_id': task.companyId,
},
)
.toList();
await _supabase.from('task_assignments').insert(assignmentsToInsert);
}
// Se non c'è una data, eliminiamo tutti i vecchi promemoria dell'utente loggato per pulizia
if (task.dueDate == null) {
await _supabase
.from('task_reminders')
.delete()
.eq('task_id', taskId)
.eq('staff_id', currentUserId)
.eq('is_forced', false);
return;
}
// 3. GESTIONE REMINDER: Puliamo SOLO quelli modificabili dall'utente loggato
await _supabase
.from('task_reminders')
.delete()
.eq('task_id', taskId)
.eq('staff_id', currentUserId)
.eq('is_forced', false); // NON tocchiamo quelli forzati dal manager!
// 4. Inseriamo le nuove configurazioni salvate dal Cubit (solo se è ancora tra gli assegnatari)
if (assignedStaffIds.contains(currentUserId) &&
currentUserCustomReminders.isNotEmpty) {
final List<Map<String, dynamic>> toInsert = [];
for (var config in currentUserCustomReminders) {
final triggerAt = task.dueDate!.subtract(
Duration(minutes: config.minutesBefore),
);
if (triggerAt.isAfter(DateTime.now())) {
toInsert.add(
_buildReminderRow(
task,
taskId,
currentUserId,
config,
triggerAt,
false,
),
);
}
}
if (toInsert.isNotEmpty) {
await _supabase.from('task_reminders').insert(toInsert);
}
}
} catch (e) {
throw Exception('Errore durante l\'aggiornamento del task: $e');
}
}
// --- HELPER PRIVATO PER LA MAPPA DEL REMINDER ---
Map<String, dynamic> _buildReminderRow(
TaskModel task,
String taskId,
String staffId,
TaskReminderConfig config,
DateTime triggerAt,
bool isForced,
) {
return {
'company_id': task.companyId,
'task_id': taskId,
'staff_id': staffId,
'minutes_before': config.minutesBefore,
'channel': config.channel,
'trigger_at': triggerAt.toIso8601String(),
'is_forced': isForced,
'is_sent': false,
};
}
}

View File

@@ -0,0 +1,65 @@
import 'package:equatable/equatable.dart';
class ReminderDefaultModel extends Equatable {
final String? id;
final String companyId;
final String staffId;
final int minutesBefore;
final String channel; // 'push' o 'email'
const ReminderDefaultModel({
this.id,
required this.companyId,
required this.staffId,
required this.minutesBefore,
required this.channel,
});
ReminderDefaultModel copyWith({
String? id,
String? companyId,
String? staffId,
int? minutesBefore,
String? channel,
}) {
return ReminderDefaultModel(
id: id ?? this.id,
companyId: companyId ?? this.companyId,
staffId: staffId ?? this.staffId,
minutesBefore: minutesBefore ?? this.minutesBefore,
channel: channel ?? this.channel,
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'company_id': companyId,
'staff_id': staffId,
'minutes_before': minutesBefore,
'channel': channel,
};
}
factory ReminderDefaultModel.fromMap(Map<String, dynamic> map) {
return ReminderDefaultModel(
id: map['id'] as String?,
companyId: map['company_id'] as String,
staffId: map['staff_id'] as String,
minutesBefore: map['minutes_before'] as int,
channel: map['channel'] as String,
);
}
@override
List<Object?> get props => [id, companyId, staffId, minutesBefore, channel];
// Helper per la UI: formatta i minuti in qualcosa di leggibile (es. "1 ora prima")
String get friendlyTime {
if (minutesBefore < 60) return '$minutesBefore minuti prima';
if (minutesBefore == 60) return '1 ora prima';
if (minutesBefore < 1440) return '${minutesBefore ~/ 60} ore prima';
if (minutesBefore == 1440) return '1 giorno prima';
return '${minutesBefore ~/ 1440} giorni prima';
}
}

View File

@@ -29,6 +29,8 @@ class TaskModel extends Equatable {
this.storeId,
});
bool get isGlobal => storeId == null;
// --- FACTORY: MODELLO VUOTO (Per le creazioni) ---
factory TaskModel.empty({String? companyId, String? createdById}) {
return TaskModel(

View File

@@ -0,0 +1,22 @@
import 'package:equatable/equatable.dart';
class TaskReminderConfig extends Equatable {
final int minutesBefore;
final String channel; // 'push' o 'email'
const TaskReminderConfig({
required this.minutesBefore,
required this.channel,
});
String get friendlyTime {
if (minutesBefore < 60) return '$minutesBefore minuti prima';
if (minutesBefore == 60) return '1 ora prima';
if (minutesBefore < 1440) return '${minutesBefore ~/ 60} ore prima';
if (minutesBefore == 1440) return '1 giorno prima';
return '${minutesBefore ~/ 1440} giorni prima';
}
@override
List<Object?> get props => [minutesBefore, channel];
}

View File

@@ -31,6 +31,62 @@ class _TaskFormScreenState extends State<TaskFormScreen> {
super.dispose();
}
void _showAddReminderDialog(BuildContext context, TaskFormCubit cubit) {
int minutes = 15;
String channel = 'push';
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Aggiungi Promemoria'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<int>(
initialValue: minutes,
decoration: const InputDecoration(labelText: 'Preavviso'),
items: const [
DropdownMenuItem(value: 5, child: Text('5 minuti prima')),
DropdownMenuItem(value: 15, child: Text('15 minuti prima')),
DropdownMenuItem(value: 60, child: Text('1 ora prima')),
DropdownMenuItem(value: 1440, child: Text('1 giorno prima')),
],
onChanged: (v) => {if (v != null) minutes = v},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: channel,
decoration: const InputDecoration(labelText: 'Canale'),
items: const [
DropdownMenuItem(value: 'push', child: Text('Notifica Push')),
DropdownMenuItem(value: 'email', child: Text('Email')),
],
onChanged: (v) => {if (v != null) channel = v},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annulla'),
),
TextButton(
onPressed: () {
cubit.addReminderRule(minutes, channel);
Navigator.pop(dialogContext);
},
child: const Text(
'Inserisci',
style: TextStyle(color: Colors.orange),
),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<TaskFormCubit, TaskFormState>(
@@ -78,9 +134,7 @@ class _TaskFormScreenState extends State<TaskFormScreen> {
)
else
TextButton.icon(
onPressed: state.isFormValid
? () => cubit.saveTask(currentUserId: 'TODO_USER_ID')
: null,
onPressed: state.isFormValid ? () => cubit.saveTask() : null,
icon: const Icon(Icons.save),
label: const Text('Salva'),
style: TextButton.styleFrom(
@@ -162,7 +216,8 @@ class _TaskFormScreenState extends State<TaskFormScreen> {
TaskFormState state,
TaskFormCubit cubit,
) {
return Column(
return FocusTraversalGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
@@ -220,7 +275,8 @@ class _TaskFormScreenState extends State<TaskFormScreen> {
leading: const Icon(Icons.calendar_today, color: Colors.orange),
title: Text(
state.dueDate != null
? 'Scadenza: ${state.dueDate!.day}/${state.dueDate!.month}/${state.dueDate!.year}'
// Formattiamo aggiungendo gli zeri (es. 05/09/2026 alle 09:05)
? 'Scadenza: ${state.dueDate!.day.toString().padLeft(2, '0')}/${state.dueDate!.month.toString().padLeft(2, '0')}/${state.dueDate!.year} alle ${state.dueDate!.hour.toString().padLeft(2, '0')}:${state.dueDate!.minute.toString().padLeft(2, '0')}'
: 'Nessuna scadenza impostata',
),
trailing: state.dueDate != null
@@ -230,16 +286,111 @@ class _TaskFormScreenState extends State<TaskFormScreen> {
)
: null,
onTap: () async {
// 1. Chiediamo prima la Data
final date = await showDatePicker(
context: context,
initialDate: state.dueDate ?? DateTime.now(),
firstDate: DateTime.now(),
lastDate: DateTime(2100),
);
if (date != null) cubit.updateDueDate(date);
// Se l'utente chiude il calendario senza scegliere, ci fermiamo
if (date == null || !context.mounted) return;
// 2. Chiediamo subito dopo l'Orario
final time = await showTimePicker(
context: context,
initialTime: state.dueDate != null
? TimeOfDay.fromDateTime(state.dueDate!)
: const TimeOfDay(hour: 9, minute: 0), // Default ore 09:00
);
// Se l'utente chiude l'orologio senza scegliere, ci fermiamo
if (time == null) return;
// 3. Fondiamo Data e Ora in un nuovo oggetto DateTime
final finalDateTime = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
// Aggiorniamo lo stato tramite il Cubit
cubit.updateDueDate(finalDateTime);
},
),
if (state.dueDate != null) ...[
const SizedBox(height: 24),
Text(
'Promemoria del Task',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
children: [
// Elenco dei promemoria attuali del form
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: state.reminders.length,
itemBuilder: (context, index) {
final reminder = state.reminders[index];
final isPush = reminder.channel == 'push';
return ListTile(
dense: true,
leading: Icon(
isPush
? Icons.notifications_active_outlined
: Icons.mail_outline,
color: isPush ? Colors.orange : Colors.blue,
),
title: Text(
reminder.friendlyTime,
style: const TextStyle(fontWeight: FontWeight.w600),
),
trailing: IconButton(
icon: const Icon(
Icons.close,
size: 18,
color: Colors.redAccent,
),
onPressed: () => cubit.removeReminderRule(index),
),
);
},
),
const Divider(),
// Tasto di aggiunta rapida promemoria
TextButton.icon(
onPressed: () => _showAddReminderDialog(context, cubit),
icon: const Icon(Icons.add, size: 18),
label: const Text('Aggiungi un promemoria a questo task'),
style: TextButton.styleFrom(
foregroundColor: Colors.orange,
),
),
],
),
),
),
],
],
),
);
}

View File

@@ -367,4 +367,22 @@ class TicketFormCubit extends Cubit<TicketFormState> {
);
}
}
Future<void> deleteTicket() async {
final currentTicket = state.ticket;
if (currentTicket.id == null || currentTicket.id!.isEmpty) return;
try {
await _repository.deleteTicket(currentTicket.id!);
emit(state.copyWith(status: TicketFormStatus.deleted));
} catch (e) {
emit(
state.copyWith(
status: TicketFormStatus.failure,
errorMessage: 'Errore durante l\'eliminazione: $e',
),
);
}
}
}

View File

@@ -2,7 +2,16 @@ import 'package:equatable/equatable.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
// Adatta gli import al tuo progetto!
enum TicketFormStatus { initial, ready, loading, saving, success, pop, failure }
enum TicketFormStatus {
initial,
ready,
loading,
saving,
success,
pop,
failure,
deleted,
}
class TicketFormState extends Equatable {
final TicketModel ticket;

View File

@@ -158,4 +158,19 @@ class TicketListCubit extends Cubit<TicketListState> {
// Opzionale: Se vuoi comunque riallinearti al server in modo silenzioso dopo l'animazione
// loadTickets(refresh: true);
}
Future<void> deleteTickets(List<TicketModel> tickets) async {
try {
for (final ticket in tickets) {
await _repository.deleteTicket(ticket.id!);
}
// Rimuoviamo i ticket localmente senza ricaricare tutto
final remainingTickets = state.tickets
.where((t) => !tickets.any((toDelete) => toDelete.id == t.id))
.toList();
emit(state.copyWith(tickets: remainingTickets, selectedTickets: {}));
} catch (e) {
emit(state.copyWith(errorMessage: e.toString()));
}
}
}

View File

@@ -75,7 +75,9 @@ class TicketRepository {
}
// --- RECUPERO PAGINATO CON FILTRI E JOIN DEI TICKET DI TUTTA L'AZIENDA ---
Future<List<TicketModel>> fetchCompanyTickets({
Future<List<TicketModel>> fetchTickets({
required String? companyId,
String? storeId,
required int offset,
int limit = 50,
String? searchTerm,
@@ -96,7 +98,7 @@ class TicketRepository {
target_model:${Tables.models}!ticket_model_id_1_fkey (*),
source_model:${Tables.models}!ticket_model_id_2_fkey (*)
''')
.eq('company_id', GetIt.I.get<SessionCubit>().state.company!.id!);
.eq('company_id', companyId!);
// Filtro Range Date
if (dateRange != null) {
@@ -105,6 +107,10 @@ class TicketRepository {
.lte('created_at', dateRange.end.toIso8601String());
}
if (storeId != null) {
query = query.or('store_id.eq.$storeId,store_id.is.null');
}
if (ticketStatusFilter != null) {
query = query.eq('status', ticketStatusFilter.value);
}

View File

@@ -348,6 +348,32 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
trackingCubit.loadTrackings(ticketId, TrackingParentType.ticket);
}
void _deleteTicket(TicketModel ticket, {Color color = Colors.red}) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Conferma Cancellazione'),
content: Text(
'Sei sicuro di voler cancellare il ticket "${ticket.referenceId}"? Questa azione è irreversibile.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annulla'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: color),
onPressed: () {
context.read<TicketFormCubit>().deleteTicket();
Navigator.of(context).pop(); // Chiude il dialog
},
child: const Text('Cancella Ticket'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -359,6 +385,10 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
_syncTextControllers(state.ticket);
}
if (state.status == TicketFormStatus.deleted) {
Navigator.of(context).pop();
}
if (state.status == TicketFormStatus.success) {
context.read<TicketListCubit>().loadTickets(refresh: true);
_showSuccessActions(
@@ -388,32 +418,33 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
: 'Modifica Ticket - Operatore: ${state.ticket.createdByName}',
),
actions: [
BlocBuilder<TicketFormCubit, TicketFormState>(
builder: (context, state) {
final ticket = state.ticket;
// Se il ticket non è ancora salvato, niente azioni rapide
if (ticket.id == null || ticket.id!.isEmpty) {
return const SizedBox.shrink();
}
// CONDIZIONE A: Da iniziare
if (ticket.id != null) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: FilledButton.icon(
onPressed: () => _deleteTicket(ticket, color: Colors.red),
icon: const Icon(Icons.delete),
label: const Text('Cancella Ticket'),
),
),
],
if (ticket.ticketStatus == TicketStatus.open ||
ticket.ticketStatus == TicketStatus.waitingForParts) {
return Padding(
ticket.ticketStatus == TicketStatus.waitingForParts) ...[
// CONDIZIONE A: Da iniziare
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor:
Colors.amber.shade700, // Colore Action
backgroundColor: Colors.amber.shade700, // Colore Action
),
onPressed: () async {
StaffMemberModel? takenBy = await getStaffMember(
context,
);
StaffMemberModel? takenBy = await getStaffMember(context);
if (takenBy == null || !context.mounted) return;
context.read<TicketFormCubit>().takeInCharge(
staffId: takenBy.id!,
@@ -427,11 +458,10 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
style: TextStyle(color: Colors.white),
),
),
);
}
// CONDIZIONE B: Già in lavorazione
else if (ticket.ticketStatus == TicketStatus.inProgress) {
return Padding(
),
],
if (ticket.ticketStatus == TicketStatus.inProgress) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
@@ -441,13 +471,8 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
icon: const Icon(Icons.handyman),
label: const Text('Vai a Lavorazione'),
),
);
}
// Se è chiuso o in altri stati strani, nascondiamo il bottone
return const SizedBox.shrink();
},
),
],
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Chip(

View File

@@ -78,6 +78,12 @@ class TicketList extends StatelessWidget {
}
}
void _deleteTickets(BuildContext context) {
context.read<TicketListCubit>().deleteTickets(
state.selectedTickets.toList(),
);
}
@override
Widget build(BuildContext context) {
return Stack(
@@ -178,6 +184,11 @@ class TicketList extends StatelessWidget {
runSpacing: 8.0,
alignment: WrapAlignment.end,
children: [
IconButton.filled(
tooltip: 'Elimina',
onPressed: () => _deleteTickets(context),
icon: const Icon(Icons.delete),
),
IconButton.filled(
tooltip: 'Riconsegna',
onPressed: () => _setStatusClosed(context),

89
lib/firebase_options.dart Normal file
View File

@@ -0,0 +1,89 @@
// File generated by FlutterFire CLI.
// ignore_for_file: type=lint
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
return web;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.macOS:
return macos;
case TargetPlatform.windows:
return windows;
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions web = FirebaseOptions(
apiKey: 'AIzaSyACOLz2mY8fHd5RWfJmDvN53LCd5_TxI6o',
appId: '1:249756116297:web:7c652e51004414b7cf2698',
messagingSenderId: '249756116297',
projectId: 'flux-87e49',
authDomain: 'flux-87e49.firebaseapp.com',
storageBucket: 'flux-87e49.firebasestorage.app',
measurementId: 'G-6V4VN8GWWZ',
);
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyA6-uX6504B3yofeo7YQwfQaS0cCDoZnvY',
appId: '1:249756116297:android:a2c3d37323752069cf2698',
messagingSenderId: '249756116297',
projectId: 'flux-87e49',
storageBucket: 'flux-87e49.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyAllwaoNyqHsZtqfMMo9DxVS6-q7yBwWow',
appId: '1:249756116297:ios:fe9dadca7150da16cf2698',
messagingSenderId: '249756116297',
projectId: 'flux-87e49',
storageBucket: 'flux-87e49.firebasestorage.app',
iosBundleId: 'com.catellisrl.flux',
);
static const FirebaseOptions macos = FirebaseOptions(
apiKey: 'AIzaSyAllwaoNyqHsZtqfMMo9DxVS6-q7yBwWow',
appId: '1:249756116297:ios:fe9dadca7150da16cf2698',
messagingSenderId: '249756116297',
projectId: 'flux-87e49',
storageBucket: 'flux-87e49.firebasestorage.app',
iosBundleId: 'com.catellisrl.flux',
);
static const FirebaseOptions windows = FirebaseOptions(
apiKey: 'AIzaSyACOLz2mY8fHd5RWfJmDvN53LCd5_TxI6o',
appId: '1:249756116297:web:b094277c2fedb425cf2698',
messagingSenderId: '249756116297',
projectId: 'flux-87e49',
authDomain: 'flux-87e49.firebaseapp.com',
storageBucket: 'flux-87e49.firebasestorage.app',
measurementId: 'G-8E29KT6RWX',
);
}

View File

@@ -1,15 +1,18 @@
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flux/core/services/notification_service.dart';
import 'package:flux/core/utils/version_check_service.dart';
import 'package:flux/features/attachments/data/attachments_repository.dart';
import 'package:flux/features/auth/bloc/auth_cubit.dart';
import 'package:flux/features/company/data/company_repository.dart';
import 'package:flux/features/notes/blocs/notes_bloc.dart';
import 'package:flux/features/notes/blocs/notes_cubit.dart';
import 'package:flux/features/notes/data/notes_repository.dart';
import 'package:flux/features/settings/data/settings_repository.dart';
import 'package:flux/features/tasks/data/task_repository.dart';
import 'package:flux/features/tickets/data/tickets_shipping_repository.dart';
import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart';
@@ -21,6 +24,7 @@ import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart';
import 'package:flux/features/tickets/data/ticket_repository.dart';
import 'package:flux/features/tracking/blocs/tracking_cubit.dart';
import 'package:flux/features/tracking/data/tracking_repository.dart';
import 'package:flux/firebase_options.dart';
import 'package:flux/l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
@@ -40,18 +44,34 @@ import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/data/staff_repository.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
import 'package:flux/features/master_data/store/data/store_repository.dart';
import 'package:flux/features/settings/settings.dart';
import 'package:flux/features/settings/ui/settings.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:url_launcher/url_launcher.dart';
String? initialRecoveryFragment;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final initialUri = Uri.base;
if (initialUri.fragment.contains('access_token=')) {
initialRecoveryFragment = initialUri.fragment;
}
await dotenv.load(fileName: ".env");
// Inizializza le dipendenze PRIMA di lanciare l'app
await setupLocator();
// RIMUOVE IL CARATTERE # DAGLI URL WEB!
usePathUrlStrategy();
// Lo Scudo Ninja: Inizializziamo Firebase SOLO sulle piattaforme supportate
if (kIsWeb || Platform.isAndroid || Platform.isIOS || Platform.isMacOS) {
try {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
await setupInteractedMessage();
} catch (e) {
debugPrint('Errore inizializzazione Firebase: $e');
}
}
runApp(
MultiBlocProvider(
providers: [
@@ -78,7 +98,7 @@ void main() async {
BlocProvider<TicketListCubit>(create: (_) => TicketListCubit()),
BlocProvider<OperationListCubit>(create: (_) => OperationListCubit()),
BlocProvider<TrackingCubit>(create: (_) => TrackingCubit()),
BlocProvider<NotesBloc>(create: (_) => NotesBloc()),
BlocProvider<NotesCubit>(create: (_) => NotesCubit()),
],
child: const FluxApp(),
),
@@ -137,7 +157,8 @@ Future<void> setupLocator() async {
() => TicketsShippingRepository(),
);
getIt.registerLazySingleton<NotesRepository>(() => NotesRepository());
getIt.registerLazySingleton<TaskRepository>(() => TaskRepository());
getIt.registerLazySingleton<TasksRepository>(() => TasksRepository());
getIt.registerLazySingleton<SettingsRepository>(() => SettingsRepository());
}
class FluxApp extends StatefulWidget {
@@ -180,6 +201,13 @@ class _FluxAppState extends State<FluxApp> {
return _buildLoadingScreen();
}
if (sessionState.status == SessionStatus.error) {
return _buildSessionErrorScreen(
state: sessionState,
context: context,
);
}
return BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, themeState) {
return MaterialApp.router(
@@ -231,6 +259,42 @@ class _FluxAppState extends State<FluxApp> {
}
}
Widget _buildSessionErrorScreen({
required SessionState state,
required BuildContext context,
}) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.wifi_off_rounded, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
state.errorMessage ?? 'Errore nella connessione',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
// Il ninja riprova a lanciare l'inizializzazione
context.read<SessionCubit>().initializeSession();
},
icon: const Icon(Icons.refresh),
label: const Text("Riprova a connetterti"),
),
],
),
),
),
),
);
}
// --- IL WIDGET GUARDIANO DEGLI AGGIORNAMENTI ---
class GlobalUpdateChecker extends StatefulWidget {
final Widget child;

View File

@@ -8,6 +8,8 @@ import Foundation
import app_links
import file_picker
import file_selector_macos
import firebase_core
import firebase_messaging
import package_info_plus
import pdfx
import printing
@@ -18,6 +20,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PdfxPlugin.register(with: registry.registrar(forPlugin: "PdfxPlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))

View File

@@ -28,8 +28,9 @@
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
47B861EC08643C31319819EE /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9AD6F0633B38D7C51DB0A44A /* Pods_RunnerTests.framework */; };
BC7B14BF366111D5491A16DE /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9C1847336C291D2358A2A03 /* Pods_Runner.framework */; };
654626D9777B906635ABD770 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5541A1B1FDEB6E0619C1BD7E /* GoogleService-Info.plist */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
BC7B14BF366111D5491A16DE /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9C1847336C291D2358A2A03 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -79,7 +80,9 @@
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
5541A1B1FDEB6E0619C1BD7E /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
64CEA5375FF158D0C9BAC5E3 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
7B4E4499661A94FDCB94A882 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
@@ -89,7 +92,6 @@
EF12DE99A4F7A47E54D9243F /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
F673AA005B814BAB5FA49C69 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
F9C1847336C291D2358A2A03 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -141,6 +143,7 @@
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
BABCD4273B81B107DD58605D /* Pods */,
5541A1B1FDEB6E0619C1BD7E /* GoogleService-Info.plist */,
);
sourceTree = "<group>";
};
@@ -235,9 +238,6 @@
productType = "com.apple.product-type.bundle.unit-test";
};
33CC10EC2044A3C60003C045 /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@@ -255,6 +255,9 @@
33CC11202044C79F0003C045 /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* flux.app */;
productType = "com.apple.product-type.application";
@@ -263,9 +266,6 @@
/* Begin PBXProject section */
33CC10E52044A3C60003C045 /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
@@ -302,6 +302,9 @@
Base,
);
mainGroup = 33CC10E42044A3C60003C045;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -327,6 +330,7 @@
files = (
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
654626D9777B906635ABD770 /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -806,12 +810,14 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;

View File

@@ -0,0 +1,122 @@
{
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382",
"version" : "12.14.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
"version" : "3.6.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "219e564a8510e983e675c94f77f7f7c50049f22d",
"version" : "12.14.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
}
}
],
"version" : 2
}

View File

@@ -0,0 +1,122 @@
{
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382",
"version" : "12.14.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
"version" : "3.6.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "219e564a8510e983e675c94f77f7f7c50049f22d",
"version" : "12.14.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
}
}
],
"version" : 2
}

View File

@@ -6,26 +6,19 @@
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.print</key>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.print</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyAllwaoNyqHsZtqfMMo9DxVS6-q7yBwWow</string>
<key>GCM_SENDER_ID</key>
<string>249756116297</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.catellisrl.flux</string>
<key>PROJECT_ID</key>
<string>flux-87e49</string>
<key>STORAGE_BUCKET</key>
<string>flux-87e49.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:249756116297:ios:fe9dadca7150da16cf2698</string>
</dict>
</plist>

View File

@@ -4,25 +4,17 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.print</key>
<key>com.apple.security.files.downloads.read-write</key>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.print</key>
<true/>
<true/>
</dict>
</plist>

View File

@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "8f89e371e2883de35cdc78f648e725fa4da5f3b6c927269f00fa68f1ea92b598"
url: "https://pub.dev"
source: hosted
version: "1.3.71"
app_links:
dependency: transitive
description:
@@ -257,6 +265,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "93a5bde9775fd5adcc937f39dfa04ae0bc89c4d79bea6abc49de3f7b049d9ff6"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: "7c98f10b8c8e5adedc0b810b66a877120696675e2c22d9ca9caca092da0d9e57"
url: "https://pub.dev"
source: hosted
version: "3.7.0"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "8d0dc81a31cd030170508dc3e89bfd14355b20a1b991340af5f018e37daab5d7"
url: "https://pub.dev"
source: hosted
version: "16.2.2"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "37abb0b0535c5497605ee94c12470e1ebbbe47e71a22d0c20bffcc912311f8cb"
url: "https://pub.dev"
source: hosted
version: "4.7.11"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: "54e22b43e2c26a2728a3f68c188de0f9011993ae19ae959a06d476dad935c776"
url: "https://pub.dev"
source: hosted
version: "4.1.7"
fixnum:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
name: flux
description: "Gestione attività negozio di telefonia"
publish_to: 'none'
version: 1.0.18+18
version: 1.1.14+32
environment:
sdk: ^3.11.3
@@ -39,6 +39,8 @@ dependencies:
flutter_launcher_icons: ^0.14.4
package_info_plus: ^9.0.1
flutter_staggered_grid_view: ^0.7.0
firebase_core: ^4.9.0
firebase_messaging: ^16.2.2
dependency_overrides:
pdfx:

8
supabase/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local

435
supabase/config.toml Normal file
View File

@@ -0,0 +1,435 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "flux"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
# Controls whether new tables, views, sequences and functions created in the `public` schema by
# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)
# without explicit GRANTs. Leave unset today to preserve local behaviour. The implicit default
# flips to `false` on 2026-05-30 to match the new cloud default, and the field is removed in
# 2026-10-30 once the always-revoked behaviour is permanent. Set to `false` to opt in early.
# auto_expose_new_tables = false
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
# Configure WebAuthn relying party settings (required when passkey is enabled).
# [auth.webauthn]
# rp_display_name = "Supabase"
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth callback URL derived from auth.external_url.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# [experimental.pgdelta]
# When enabled, pg-delta becomes the active engine for supported schema flows.
# enabled = false
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./database"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
[functions.send-reminders]
enabled = true
verify_jwt = false
import_map = "./functions/send-reminders/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/send-reminders/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/send-reminders/*.html" ]
[functions.instant-notifier]
enabled = true
verify_jwt = false
import_map = "./functions/instant-notifier/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/instant-notifier/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/instant-notifier/*.html" ]

View File

@@ -0,0 +1,3 @@
# Configuration for private npm package dependencies
# For more information on using private registries with Edge Functions, see:
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries

View File

@@ -0,0 +1,6 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2",
"@supabase/server": "npm:@supabase/server@^1"
}
}

View File

@@ -0,0 +1,206 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"
import { JWT } from "https://esm.sh/google-auth-library@8.9.0"
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apiKey, content-type',
}
serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
try {
const bodyText = await req.text();
const payload = JSON.parse(bodyText);
// Estraggo i dati dal payload standard di Supabase
const tableName = payload.table;
const record = payload.record;
if (!tableName || !record) {
throw new Error("Payload non valido, manca table o record.");
}
let event_type = '';
let target_staff_id = '';
let title = '';
let description = '';
let reference_id = '';
// Inizializziamo il client Supabase subito, ci serve per le query
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
// SMISTAMENTO IN BASE ALLA TABELLA
if (tableName === 'task_assignments') {
event_type = 'task_assigned';
target_staff_id = record.staff_id;
reference_id = record.task_id;
title = 'Nuovo Task Assegnato';
// 1. Peschiamo i dettagli completi del task
const { data: taskData } = await supabaseClient
.from('tasks')
.select('*')
.eq('id', reference_id)
.single();
// 2. Peschiamo il nome del creatore
let creatorName = "Admin";
if (taskData?.created_by_id) {
const { data: creatorData } = await supabaseClient
.from('staff_members')
.select('first_name, last_name')
.eq('id', taskData.created_by_id)
.single();
if (creatorData) {
creatorName = `${creatorData.first_name} ${creatorData.last_name}`.trim();
}
}
// 3. Formattiamo la data (se esiste)
let dueDateStr = 'Nessuna scadenza';
if (taskData?.due_date) {
const d = new Date(taskData.due_date);
dueDateStr = d.toLocaleDateString('it-IT');
}
// 4. Costruiamo il Body multilinea per Android
const taskTitle = taskData?.title || 'Senza titolo';
const taskDesc = taskData?.description || 'Nessuna descrizione fornita.';
description = `${taskTitle}\n\nCreato da: ${creatorName}\nScadenza: ${dueDateStr}\nDettagli: ${taskDesc}`;
}
// 1. Leggiamo le preferenze specifiche di questo dipendente
const { data: settings, error: settingsError } = await supabaseClient
.from('staff_notification_settings')
.select('*')
.eq('staff_id', target_staff_id)
.single()
if (settingsError || !settings) throw new Error('Preferenze utente non trovate')
// 2. Determiniamo QUALI canali usare in base all'evento e agli switch dell'utente
let sendPush = false
let sendEmail = false
switch (event_type) {
case 'task_assigned':
sendPush = settings.task_assigned_push
sendEmail = settings.task_assigned_email
break
case 'note_invited':
sendPush = settings.note_invited_push
sendEmail = settings.note_invited_email
break
case 'new_operation':
sendPush = settings.new_operation_push
sendEmail = settings.new_operation_email
break
case 'new_ticket':
sendPush = settings.new_ticket_push
sendEmail = settings.new_ticket_email
break
default:
throw new Error('Tipo evento non riconosciuto')
}
// Se l'utente ha spento tutto, interrompiamo subito risparmiando risorse
if (!sendPush && !sendEmail) {
return new Response(JSON.stringify({ message: 'L\'utente ha disattivato le notifiche per questo evento.' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200
})
}
// Se arriviamo qui, dobbiamo inviare qualcosa. Prepariamo i dati dell'utente.
const { data: staffMember } = await supabaseClient
.from('staff_members')
.select('email, first_name')
.eq('id', target_staff_id)
.single()
// 3. LOGICA PUSH (FCM)
if (sendPush) {
const firebaseSecret = Deno.env.get('FIREBASE_SERVICE_ACCOUNT');
if (!firebaseSecret) {
console.error("ERRORE: Secret FIREBASE_SERVICE_ACCOUNT mancante nel progetto!");
} else {
const credentials = JSON.parse(firebaseSecret);
const jwtClient = new JWT({
email: credentials.client_email,
key: credentials.private_key,
scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
});
const fcmAccessToken = (await jwtClient.getAccessToken()).token;
const { data: devices } = await supabaseClient
.from('staff_devices')
.select('fcm_token')
.eq('staff_id', target_staff_id);
if (devices && devices.length > 0) {
for (const device of devices) {
try {
const res = await fetch(`https://fcm.googleapis.com/v1/projects/${credentials.project_id}/messages:send`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${fcmAccessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
message: {
token: device.fcm_token,
notification: { title, body: description },
data: { click_action: 'FLUTTER_NOTIFICATION_CLICK', eventType: event_type, referenceId: reference_id },
},
}),
});
// QUI È DOVE CATTURIAMO LA RISPOSTA DI GOOGLE
const fcmResponseData = await res.json();
if (!res.ok) {
console.error("FCM HA RIFIUTATO LA NOTIFICA:", fcmResponseData);
}
} catch (err) {
console.error('Errore di rete durante invio Push:', err);
}
}
}
}
}
// 4. LOGICA EMAIL (Resend)
if (sendEmail && staffMember?.email) {
const resendApiKey = Deno.env.get('RESEND_API_KEY')
if (resendApiKey) {
try {
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { 'Authorization': `Bearer ${resendApiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: 'FLUX Notifiche <onboarding@resend.dev>',
to: staffMember.email,
subject: title,
html: `<p>Ciao ${staffMember.first_name},</p><p>${description}</p><p><br>Il team FLUX</p>`,
}),
})
} catch (err) { console.error('Errore invio Email:', err) }
}
}
return new Response(JSON.stringify({ success: true, push_sent: sendPush, email_sent: sendEmail }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200
})
} catch (error) {
console.error("ERRORE FATALE NELLA FUNZIONE:", error);
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500
})
}
})

View File

@@ -0,0 +1,129 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.7.1'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
serve(async (req) => {
// 1. GESTIONE CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
// 2. ESTRAZIONE EMAIL
const { email } = await req.json()
if (!email) {
throw new Error("Devi fornire un indirizzo email.")
}
// 3. INIZIALIZZAZIONE CLIENT ADMIN
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
// 4. RECUPERO NOME UTENTE (Opzionale, ma fa molto pro per l'email)
const { data: staffData } = await supabaseAdmin
.from('staff_members')
.select('name')
.eq('email', email)
.single()
const userName = staffData?.name || "Socio"
// 5. GENERAZIONE DEL LINK DI RECOVERY
// Usiamo lo stesso identico URL di redirect che abbiamo blindato prima!
const { data: linkData, error: linkError } = await supabaseAdmin.auth.admin.generateLink({
type: 'recovery',
email: email,
options: {
redirectTo: 'https://flux.catelli.it/set-password'
}
})
if (linkError) {
// Se l'utente non esiste su Auth, Supabase lancia un errore.
// Per sicurezza (evitare l'enumerazione delle email), restituiamo comunque un 200 finto
// oppure intercettiamo l'errore per il debug.
console.error("Errore generazione link Supabase:", linkError)
throw new Error("Se l'email è registrata, riceverai a breve le istruzioni.")
}
const secureLink = linkData.properties.action_link
// 6. IL POSTINO RESEND
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY')
if (!RESEND_API_KEY) throw new Error("Chiave API di Resend non configurata.")
const emailHtml = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; max-width: 550px; margin: 40px auto; padding: 32px; border: 1px solid #e2e8f0; border-radius: 16px; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
<div style="margin-bottom: 32px; text-align: left;">
<img src="https://flux.catelli.it/assets/flux_logo_dark.svg" width="150" style="display: block; height: auto; border: 0;" alt="FLUX Logo" />
</div>
<h2 style="color: #0f172a; font-size: 22px; font-weight: 700; margin-bottom: 16px; margin-top: 0; letter-spacing: -0.3px;">
Ripristino Password
</h2>
<p style="color: #334155; font-size: 15px; line-height: 1.6; margin-bottom: 20px; margin-top: 0;">
Ciao <strong>${userName}</strong>,
</p>
<p style="color: #334155; font-size: 15px; line-height: 1.6; margin-bottom: 24px;">
Abbiamo ricevuto una richiesta per reimpostare la password del tuo account su FLUX. Clicca sul pulsante qui sotto per sceglierne una nuova.
</p>
<div style="margin: 36px 0; text-align: center;">
<a href="${secureLink}" style="background-color: #0f172a; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-size: 15px; font-weight: 600; display: inline-block; box-shadow: 0 4px 6px -1px rgba(15, 23, 42, 0.2);">
Reimposta la tua Password
</a>
</div>
<div style="border-top: 1px solid #f1f5f9; padding-top: 20px; margin-top: 32px;">
<p style="color: #94a3b8; font-size: 12px; line-height: 1.5; margin-bottom: 0;">
Se non hai richiesto tu il ripristino, puoi ignorare tranquillamente questa email. Il link è strettamente personale e valido per un solo utilizzo.<br><br>
<a href="${secureLink}" style="color: #2563eb; text-decoration: none; word-break: break-all;">${secureLink}</a>
</p>
</div>
</div>
`
// Qui puoi cambiare l'indirizzo mittente se vuoi separare gli inviti dal supporto tecnico
const resendResponse = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${RESEND_API_KEY}`,
},
body: JSON.stringify({
from: 'FLUX Support <support@flux.catelli.it>', // Modificato per sembrare più istituzionale
to: [email],
subject: 'Recupero Password FLUX',
html: emailHtml,
}),
})
if (!resendResponse.ok) {
const resendError = await resendResponse.text()
console.error("Resend ha fallito:", resendError)
throw new Error("Errore durante l'invio dell'email.")
}
// 7. TUTTO OK
return new Response(
JSON.stringify({ success: true, message: "Email inviata con successo." }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
)
} catch (error: any) {
console.error("CRASH FUNZIONE RESET:", error.message || error);
return new Response(
JSON.stringify({ error: error.message || "Errore generico del server" }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
})

View File

@@ -0,0 +1,3 @@
# Configuration for private npm package dependencies
# For more information on using private registries with Edge Functions, see:
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries

View File

@@ -0,0 +1,6 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2",
"@supabase/server": "npm:@supabase/server@^1"
}
}

View File

@@ -0,0 +1,139 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"
import { JWT } from "https://esm.sh/google-auth-library@8.9.0"
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apiKey, content-type',
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
// 1. Client Supabase (Service Role)
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
// 2. Setup Firebase (Push)
const firebaseSecret = Deno.env.get('FIREBASE_SERVICE_ACCOUNT')
if (!firebaseSecret) throw new Error('Missing FIREBASE_SERVICE_ACCOUNT secret')
const credentials = JSON.parse(firebaseSecret)
const jwtClient = new JWT({
email: credentials.client_email,
key: credentials.private_key,
scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
})
const tokens = await jwtClient.getAccessToken()
const fcmAccessToken = tokens.token
// 3. Setup Resend (Email)
const resendApiKey = Deno.env.get('RESEND_API_KEY')
if (!resendApiKey) throw new Error('Missing RESEND_API_KEY secret')
// 4. Recupero Promemoria (TUTTI i canali, non ancora inviati)
const { data: reminders, error: fetchError } = await supabaseClient
.from('task_reminders')
.select(`
id, task_id, staff_id, channel,
tasks(title, description),
staff_members(email, first_name)
`)
.eq('is_sent', false)
.lte('trigger_at', new Date().toISOString())
if (fetchError) throw fetchError
if (!reminders || reminders.length === 0) {
return new Response(JSON.stringify({ message: 'Nessun promemoria da inviare.' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
})
}
// 5. Smistamento ed Invio
for (const reminder of reminders) {
const taskTitle = reminder.tasks?.title || 'Nuovo Task!';
const taskBody = reminder.tasks?.description || 'Hai un task in scadenza.';
const staffEmail = reminder.staff_members?.email;
const staffName = reminder.staff_members?.first_name || 'Staff';
if (reminder.channel === 'push') {
// --- LOGICA PUSH ---
const { data: devices } = await supabaseClient
.from('staff_devices')
.select('fcm_token')
.eq('staff_id', reminder.staff_id)
if (devices && devices.length > 0) {
for (const device of devices) {
try {
await fetch(
`https://fcm.googleapis.com/v1/projects/${credentials.project_id}/messages:send`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${fcmAccessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
token: device.fcm_token,
notification: { title: taskTitle, body: taskBody },
data: { click_action: 'FLUTTER_NOTIFICATION_CLICK', taskId: reminder.task_id },
},
}),
}
)
} catch (err) {
console.error(`Errore FCM token ${device.fcm_token}:`, err)
}
}
}
} else if (reminder.channel === 'email' && staffEmail) {
// --- LOGICA EMAIL (RESEND) ---
try {
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${resendApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'FLUX Reminders <onboarding@resend.dev>', // Sostituirai con la tua email verificata in futuro
to: staffEmail,
subject: `Promemoria: ${taskTitle}`,
html: `<p>Ciao ${staffName},</p>
<p>Questo è un promemoria per il task: <strong>${taskTitle}</strong>.</p>
<p>Dettagli: ${taskBody}</p>
<p><br>Il team FLUX</p>`,
}),
})
} catch (err) {
console.error(`Errore invio Email a ${staffEmail}:`, err)
}
}
// 6. Segna come inviato
await supabaseClient
.from('task_reminders')
.update({ is_sent: true, updated_at: new Date().toISOString() })
.eq('id', reminder.id)
}
return new Response(JSON.stringify({ success: true, processed: reminders.length }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
})
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
})
}
})

Some files were not shown because too many files have changed in this diff Show More