Compare commits
55 Commits
123c006a1e
...
v1.1.22
| Author | SHA1 | Date | |
|---|---|---|---|
| f8504d466a | |||
| 22bb86f052 | |||
| fc850795c9 | |||
| b60ce96dd7 | |||
| 6582da60d4 | |||
| d42cc5af1d | |||
| 7ea0e2ac10 | |||
| 5ce0110197 | |||
| 4efc3ce182 | |||
| 01515910b6 | |||
| f27ede7625 | |||
| 99ab7abf6e | |||
| a7fd37a894 | |||
| 8ad2b7cf7e | |||
| 3210b4fcfa | |||
| a51ac8fe7f | |||
| 7fad6ee02b | |||
| 6a6e792cd9 | |||
| 3c33c8765a | |||
| 27a5bc16bc | |||
| 808de7b354 | |||
| 618cbc0396 | |||
| 67a56f2954 | |||
| 88b1a618bd | |||
| d989b14967 | |||
| d4ff2b9a7e | |||
| 06ee11521d | |||
| 55d6429dc5 | |||
| 44c85766fc | |||
| b69308e1ef | |||
| 6394e5a2cd | |||
| f31ff19a74 | |||
| 064179a753 | |||
| 727eaac3d9 | |||
| bd81173559 | |||
| 9bace01b93 | |||
| 5ad3e12b1f | |||
| 6211cc6729 | |||
| f15a2aa6e6 | |||
| aed841dc0b | |||
| 221260aca3 | |||
| 83988597d5 | |||
| b298509178 | |||
| b6e5f9acbe | |||
| f6ecb33729 | |||
| 9d796d6e41 | |||
| 45455a16c4 | |||
| 2afe97c6db | |||
| 4101b736e6 | |||
| b67354610d | |||
| b19c91a7dd | |||
| 9b5d19b926 | |||
| aad9a991c2 | |||
| 7f0d18eed1 | |||
| 879c848d77 |
@@ -21,11 +21,27 @@ jobs:
|
|||||||
- name: Build Flutter Windows
|
- name: Build Flutter Windows
|
||||||
run: flutter build windows --release
|
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
|
- name: Build Windows Installer
|
||||||
run: |
|
run: |
|
||||||
$TagVersion = "${{ github.ref_name }}".Substring(1)
|
$TagVersion = "${{ github.ref_name }}".Substring(1)
|
||||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "/DMyAppVersion=$TagVersion" "win_installer.iss"
|
& "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
|
# Nel dubbio usiamo l'action per caricare l'asset
|
||||||
- name: Upload Windows Asset
|
- name: Upload Windows Asset
|
||||||
uses: https://gitea.com/actions/release-action@main
|
uses: https://gitea.com/actions/release-action@main
|
||||||
@@ -63,6 +79,15 @@ jobs:
|
|||||||
files: "build/app/outputs/flutter-apk/app-release.apk"
|
files: "build/app/outputs/flutter-apk/app-release.apk"
|
||||||
api_key: ${{ secrets.MYRELEASE_TOKEN }}
|
api_key: ${{ secrets.MYRELEASE_TOKEN }}
|
||||||
|
|
||||||
|
- name: Aggiorna Link Android su Supabase
|
||||||
|
run: |
|
||||||
|
curl -X PATCH "https://pvqpjloswwvtfoxbkfbh.supabase.co/rest/v1/app_config?id=eq.49f18b19-2129-46c0-b690-a97db725b5a8" -H "apikey: ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Content-Type: application/json" -d "{\"download_url\": \"https://gitea.catelli.it/brontomark/flux/releases/download/${{ github.ref_name }}/app-release.apk\"}"
|
||||||
|
|
||||||
|
- name: Aggiorna Link Windows su Supabase
|
||||||
|
run: |
|
||||||
|
curl -X PATCH "https://pvqpjloswwvtfoxbkfbh.supabase.co/rest/v1/app_config?id=eq.1f888b30-5cbf-4a16-820c-5036a3af0cf8" -H "apikey: ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Content-Type: application/json" -d "{\"download_url\": \"https://gitea.catelli.it/brontomark/flux/releases/download/${{ github.ref_name }}/FluxInstaller.exe\"}"
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
# JOB 3: WEB & CLOUDFLARE DEPLOY (Gira sul tuo MacBook)
|
# JOB 3: WEB & CLOUDFLARE DEPLOY (Gira sul tuo MacBook)
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"deno.enable": true
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@ analyzer:
|
|||||||
- "lib/l10n/*.dart"
|
- "lib/l10n/*.dart"
|
||||||
- "**/*.g.dart" # Già che ci siamo escludiamo tutti i file generati (tipo quelli di JsonSerializable)
|
- "**/*.g.dart" # Già che ci siamo escludiamo tutti i file generati (tipo quelli di JsonSerializable)
|
||||||
- "**/*.freezed.dart"
|
- "**/*.freezed.dart"
|
||||||
|
- "build/**"
|
||||||
|
- "ios/**"
|
||||||
|
- "macos/**"
|
||||||
|
- ".dart_tool/**"
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
rules:
|
rules:
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
// START: FlutterFire Configuration
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
// END: FlutterFire Configuration
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
|||||||
29
android/app/google-services.json
Normal file
29
android/app/google-services.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ pluginManagement {
|
|||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
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
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
firebase.json
Normal file
1
firebase.json
Normal 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"}}}}}}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -126,6 +128,7 @@
|
|||||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
F5D002C3092D87755D552D32 /* Pods */,
|
F5D002C3092D87755D552D32 /* Pods */,
|
||||||
6A991A28CCED9666CA172E00 /* Frameworks */,
|
6A991A28CCED9666CA172E00 /* Frameworks */,
|
||||||
|
D4B70082D3146D8C2B7AFA02 /* GoogleService-Info.plist */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -267,6 +270,7 @@
|
|||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
|
101B9A4BF8F30D998DDC11D4 /* GoogleService-Info.plist in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
30
ios/Runner/GoogleService-Info.plist
Normal file
30
ios/Runner/GoogleService-Info.plist
Normal 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>
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
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:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/data/core_repository.dart';
|
import 'package:flux/core/data/core_repository.dart';
|
||||||
import 'package:flux/features/company/models/company_model.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/staff/models/staff_member_model.dart';
|
||||||
import 'package:flux/features/master_data/store/models/store_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:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'package:collection/collection.dart'; // Per firstWhereOrNull
|
import 'package:collection/collection.dart'; // Per firstWhereOrNull
|
||||||
@@ -39,112 +45,183 @@ class SessionCubit extends Cubit<SessionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
||||||
StaffMemberModel? staff = await _repository.getStaffMemberByUserId(
|
emit(state.copyWith(status: SessionStatus.initial, errorMessage: null));
|
||||||
user.id,
|
|
||||||
);
|
// WRAP DELLA LOGICA IN UN BLOCCO PROTETTO DA TIMEOUT (10 Secondi)
|
||||||
CompanyModel? company;
|
await Future(() async {
|
||||||
if (staff != null) {
|
StaffMemberModel? staff = await _repository.getStaffMemberByUserId(
|
||||||
// --- LA MAGIA DEL SENSORE ---
|
user.id,
|
||||||
if (staff.hasJoined == false) {
|
);
|
||||||
// È la primissima volta che entra! Aggiorniamo il DB.
|
CompanyModel? company;
|
||||||
await _repository.updateStaffMember(staff.id!, {'has_joined': true});
|
|
||||||
// Aggiorniamo anche il nostro modello in memoria per questa sessione
|
if (staff != null) {
|
||||||
staff = staff.copyWith(hasJoined: true);
|
if (staff.hasJoined == false) {
|
||||||
|
await _repository.updateStaffMember(staff.id!, {
|
||||||
|
'has_joined': true,
|
||||||
|
});
|
||||||
|
staff = staff.copyWith(hasJoined: true);
|
||||||
|
}
|
||||||
|
company = await _repository.getCompanyById(staff.companyId);
|
||||||
|
} else {
|
||||||
|
company = await _repository.getCompanyByOwnerId(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
company = await _repository.getCompanyById(staff.companyId);
|
if (company == null) {
|
||||||
} else {
|
return emit(
|
||||||
// È l'Admin in onboarding
|
state.copyWith(
|
||||||
company = await _repository.getCompanyByOwnerId(user.id);
|
status: SessionStatus.onboardingRequired,
|
||||||
}
|
user: user,
|
||||||
// 1. Controllo Azienda
|
onboardingStep: OnboardingStep.company,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
emit(state.copyWith(company: company));
|
||||||
|
}
|
||||||
|
|
||||||
if (staff != null) {
|
final stores = await _repository.getStoresByCompanyId(company.id!);
|
||||||
// L'utente esiste già nel sistema! Carichiamo l'azienda per cui lavora
|
if (stores.isEmpty) {
|
||||||
company = await _repository.getCompanyById(staff.companyId);
|
return emit(
|
||||||
} else {
|
state.copyWith(
|
||||||
// L'utente non ha profilo. Probabilmente è l'Admin che ha appena
|
status: SessionStatus.onboardingRequired,
|
||||||
// fatto Sign Up e sta iniziando l'Onboarding
|
user: user,
|
||||||
company = await _repository.getCompanyByOwnerId(user.id);
|
company: company,
|
||||||
}
|
onboardingStep: OnboardingStep.store,
|
||||||
if (company == null) {
|
),
|
||||||
return emit(
|
);
|
||||||
state.copyWith(
|
} else {
|
||||||
status: SessionStatus.onboardingRequired,
|
emit(state.copyWith(currentStore: stores.first));
|
||||||
user: user,
|
}
|
||||||
onboardingStep: OnboardingStep.company,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
emit(state.copyWith(company: company));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Controllo Negozi
|
if (staff == null) {
|
||||||
final stores = await _repository.getStoresByCompanyId(company.id!);
|
return emit(
|
||||||
if (stores.isEmpty) {
|
state.copyWith(
|
||||||
return emit(
|
status: SessionStatus.onboardingRequired,
|
||||||
|
user: user,
|
||||||
|
company: company,
|
||||||
|
onboardingStep: OnboardingStep.staff,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final lastStoreId = _prefs.getString(_lastStoreKey);
|
||||||
|
final activeStore =
|
||||||
|
stores.firstWhereOrNull((s) => s.id == lastStoreId) ?? stores.first;
|
||||||
|
|
||||||
|
if (lastStoreId != activeStore.id && activeStore.id != null) {
|
||||||
|
await _prefs.setString(_lastStoreKey, activeStore.id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSingleUserMode(_prefs.getBool('isSingleUserMode') ?? false);
|
||||||
|
|
||||||
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: SessionStatus.onboardingRequired,
|
status: SessionStatus.authenticated,
|
||||||
user: user,
|
user: user,
|
||||||
company: company,
|
company: company,
|
||||||
onboardingStep: OnboardingStep.store,
|
currentStore: activeStore,
|
||||||
|
currentStaffMember: staff,
|
||||||
|
onboardingStep: OnboardingStep.none,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
emit(state.copyWith(currentStore: stores.first));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Controllo Staff (Paziente Zero)
|
// FCM è fuori dall'await principale, quindi va bene così
|
||||||
if (staff == null) {
|
_registerFcmToken(companyId: company.id!, staffId: staff.id!);
|
||||||
return emit(
|
}).timeout(
|
||||||
state.copyWith(
|
const Duration(seconds: 10), // Tempo massimo concesso al server
|
||||||
status: SessionStatus.onboardingRequired,
|
onTimeout: () {
|
||||||
user: user,
|
throw TimeoutException(
|
||||||
company: company,
|
'Il server di FLUX non risponde. Controlla la connessione.',
|
||||||
onboardingStep: OnboardingStep.staff,
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
} on TimeoutException catch (e) {
|
||||||
|
// 🎯 BINGO! IL TIMEOUT È SCATTATO
|
||||||
// --- TUTTO COMPLETATO: LOGICA DEL NEGOZIO DI DEFAULT ---
|
debugPrint("Timeout Inizializzazione: ${e.message}");
|
||||||
|
|
||||||
// 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(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(status: SessionStatus.error, errorMessage: e.message),
|
||||||
status: SessionStatus.authenticated,
|
|
||||||
user: user,
|
|
||||||
company: company,
|
|
||||||
currentStore: activeStore,
|
|
||||||
currentStaffMember: staff,
|
|
||||||
onboardingStep: OnboardingStep.none, // Svuotiamo l'onboarding
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} 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(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: SessionStatus
|
status: SessionStatus.error,
|
||||||
.unauthenticated, // O un nuovo stato 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) {
|
void updateCurrentCompany(CompanyModel newCompany) {
|
||||||
emit(state.copyWith(company: newCompany));
|
emit(state.copyWith(company: newCompany));
|
||||||
}
|
}
|
||||||
@@ -170,4 +247,13 @@ class SessionCubit extends Cubit<SessionState> {
|
|||||||
void setIsSingleUserMode(bool isSingleUser) {
|
void setIsSingleUserMode(bool isSingleUser) {
|
||||||
emit(state.copyWith(isSingleUserMode: 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ enum SessionStatus {
|
|||||||
unauthenticated,
|
unauthenticated,
|
||||||
onboardingRequired,
|
onboardingRequired,
|
||||||
authenticated,
|
authenticated,
|
||||||
|
error,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Definisce lo step esatto dell'onboarding (Paranoia Mode)
|
/// Definisce lo step esatto dell'onboarding (Paranoia Mode)
|
||||||
@@ -26,6 +27,7 @@ class SessionState extends Equatable {
|
|||||||
final OnboardingStep onboardingStep;
|
final OnboardingStep onboardingStep;
|
||||||
final bool isMobileDevice;
|
final bool isMobileDevice;
|
||||||
final bool isSingleUserMode;
|
final bool isSingleUserMode;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
const SessionState({
|
const SessionState({
|
||||||
this.status = SessionStatus.initial,
|
this.status = SessionStatus.initial,
|
||||||
@@ -36,6 +38,7 @@ class SessionState extends Equatable {
|
|||||||
this.onboardingStep = OnboardingStep.none,
|
this.onboardingStep = OnboardingStep.none,
|
||||||
this.isMobileDevice = false,
|
this.isMobileDevice = false,
|
||||||
this.isSingleUserMode = false,
|
this.isSingleUserMode = false,
|
||||||
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Metodo per creare una copia dello stato modificando solo i campi necessari
|
/// Metodo per creare una copia dello stato modificando solo i campi necessari
|
||||||
@@ -48,6 +51,7 @@ class SessionState extends Equatable {
|
|||||||
OnboardingStep? onboardingStep,
|
OnboardingStep? onboardingStep,
|
||||||
bool? isMobileDevice,
|
bool? isMobileDevice,
|
||||||
bool? isSingleUserMode,
|
bool? isSingleUserMode,
|
||||||
|
String? errorMessage,
|
||||||
}) {
|
}) {
|
||||||
return SessionState(
|
return SessionState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
@@ -58,6 +62,7 @@ class SessionState extends Equatable {
|
|||||||
onboardingStep: onboardingStep ?? this.onboardingStep,
|
onboardingStep: onboardingStep ?? this.onboardingStep,
|
||||||
isMobileDevice: isMobileDevice ?? this.isMobileDevice,
|
isMobileDevice: isMobileDevice ?? this.isMobileDevice,
|
||||||
isSingleUserMode: isSingleUserMode ?? this.isSingleUserMode,
|
isSingleUserMode: isSingleUserMode ?? this.isSingleUserMode,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +76,7 @@ class SessionState extends Equatable {
|
|||||||
onboardingStep,
|
onboardingStep,
|
||||||
isMobileDevice,
|
isMobileDevice,
|
||||||
isSingleUserMode,
|
isSingleUserMode,
|
||||||
|
errorMessage,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper rapidi per la UI
|
// Helper rapidi per la UI
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
class Tables {
|
class Tables {
|
||||||
|
static const String appConfig = 'app_config';
|
||||||
static const String attachments = 'attachments';
|
static const String attachments = 'attachments';
|
||||||
static const String brands = 'brands';
|
static const String brands = 'brands';
|
||||||
static const String campaigns = 'campaigns';
|
static const String campaigns = 'campaigns';
|
||||||
@@ -16,6 +17,9 @@ class Tables {
|
|||||||
static const String staffInStores = 'staff_in_stores';
|
static const String staffInStores = 'staff_in_stores';
|
||||||
static const String staffMembers = 'staff_members';
|
static const String staffMembers = 'staff_members';
|
||||||
static const String stores = 'stores';
|
static const String stores = 'stores';
|
||||||
|
static const String tasks = 'tasks';
|
||||||
|
static const String taskAssignments = 'task_assignments';
|
||||||
|
static const String taskReminders = 'task_reminders';
|
||||||
static const String tickets = 'tickets';
|
static const String tickets = 'tickets';
|
||||||
static const String trackings = 'trackings';
|
static const String trackings = 'trackings';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,10 +89,16 @@ class _AppMenuState extends State<AppMenu> {
|
|||||||
Icon(Icons.bolt, color: theme.colorScheme.primary, size: 32),
|
Icon(Icons.bolt, color: theme.colorScheme.primary, size: 32),
|
||||||
if (!effectivelyCollapsed) ...[
|
if (!effectivelyCollapsed) ...[
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
TextButton(
|
||||||
"FLUX",
|
onPressed: () {
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
if (widget.isDrawer) Navigator.pop(context);
|
||||||
fontWeight: FontWeight.bold,
|
context.goNamed(Routes.home);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
"FLUX",
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -111,13 +117,36 @@ class _AppMenuState extends State<AppMenu> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
children: [
|
children: [
|
||||||
_buildRouteItem(
|
_buildRouteItem(
|
||||||
title: context.l10n.commonDashboard,
|
title: 'Dashboard',
|
||||||
icon: Icons.dashboard_outlined,
|
icon: Icons.dashboard_outlined,
|
||||||
routeName: Routes.home, // <--- Usiamo la tua costante!
|
routeName: Routes.home,
|
||||||
pathToCheck:
|
pathToCheck:
|
||||||
'/', // Il path da controllare per colorarlo
|
'/', // Il path da controllare per colorarlo
|
||||||
isCollapsed: effectivelyCollapsed,
|
isCollapsed: effectivelyCollapsed,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// --- SEZIONE OPERATIVA ---
|
||||||
|
_buildHierarchicalItem(
|
||||||
|
title: 'Operatività',
|
||||||
|
icon: Icons.work_outline,
|
||||||
|
basePathToCheck: '/',
|
||||||
|
isCollapsed: effectivelyCollapsed,
|
||||||
|
subItems: [
|
||||||
|
_SubMenuItem(
|
||||||
|
'Operazioni',
|
||||||
|
Routes.operations,
|
||||||
|
'/operations',
|
||||||
|
),
|
||||||
|
_SubMenuItem(
|
||||||
|
'Assistenza',
|
||||||
|
Routes.tickets,
|
||||||
|
'/tickets',
|
||||||
|
),
|
||||||
|
_SubMenuItem('Tasks', Routes.tasks, '/tasks'),
|
||||||
|
_SubMenuItem('Sticky Notes', Routes.notes, '/notes'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// --- IL MENU GERARCHICO (ANAGRAFICHE) ---
|
// --- IL MENU GERARCHICO (ANAGRAFICHE) ---
|
||||||
@@ -256,7 +285,9 @@ class _AppMenuState extends State<AppMenu> {
|
|||||||
required bool isCollapsed,
|
required bool isCollapsed,
|
||||||
required List<_SubMenuItem> subItems,
|
required List<_SubMenuItem> subItems,
|
||||||
}) {
|
}) {
|
||||||
final isSelected = widget.currentPath.startsWith(basePathToCheck);
|
final isSelected = subItems.any(
|
||||||
|
(item) => widget.currentPath.startsWith(item.pathToCheck),
|
||||||
|
);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import 'package:flux/core/layout/app_shell.dart';
|
|||||||
import 'package:flux/core/routes/routes.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/blocs/image_upload_cubit.dart';
|
||||||
import 'package:flux/core/widgets/image_upload/ui/image_upload_screen.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/core/widgets/image_upload/ui/upload_success_screen.dart';
|
||||||
import 'package:flux/features/auth/ui/auth_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/bloc/company_settings_cubit.dart';
|
||||||
import 'package:flux/features/company/ui/company_settings_screen.dart';
|
import 'package:flux/features/company/ui/company_settings_screen.dart';
|
||||||
import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
|
import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
|
||||||
@@ -18,6 +18,10 @@ 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_detail_screen.dart';
|
||||||
import 'package:flux/features/customers/ui/customer_form_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/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/home/ui/home_screen.dart';
|
||||||
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
||||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
||||||
@@ -27,8 +31,10 @@ import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.da
|
|||||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||||
import 'package:flux/features/master_data/providers/ui/provider_form_screen.dart';
|
import 'package:flux/features/master_data/providers/ui/provider_form_screen.dart';
|
||||||
import 'package:flux/features/master_data/providers/ui/provider_list_screen.dart';
|
import 'package:flux/features/master_data/providers/ui/provider_list_screen.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/master_data/staff/models/staff_member_model.dart';
|
||||||
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
|
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
|
||||||
|
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
|
||||||
import 'package:flux/features/master_data/store/ui/stores_screen.dart';
|
import 'package:flux/features/master_data/store/ui/stores_screen.dart';
|
||||||
import 'package:flux/features/notes/models/note_model.dart';
|
import 'package:flux/features/notes/models/note_model.dart';
|
||||||
import 'package:flux/features/notes/ui/notes_form_screen.dart';
|
import 'package:flux/features/notes/ui/notes_form_screen.dart';
|
||||||
@@ -40,8 +46,15 @@ import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
|
|||||||
import 'package:flux/features/operations/models/operation_model.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_form_screen.dart';
|
||||||
import 'package:flux/features/operations/ui/operation_list_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/blocs/reminder_defaults_cubit.dart';
|
||||||
import 'package:flux/features/settings/theme_settings_view.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';
|
||||||
|
import 'package:flux/features/tasks/ui/task_form_screen.dart';
|
||||||
|
import 'package:flux/features/tasks/ui/task_list_screen.dart';
|
||||||
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
|
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||||
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
|
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
|
||||||
@@ -53,10 +66,16 @@ import 'package:get_it/get_it.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class AppRouter {
|
class AppRouter {
|
||||||
|
// 1. CREIAMO LA CHIAVE GLOBALE DEL NAVIGATORE
|
||||||
|
static final GlobalKey<NavigatorState> rootNavigatorKey =
|
||||||
|
GlobalKey<NavigatorState>();
|
||||||
|
static String? pendingRoute;
|
||||||
static GoRouter createRouter(SessionCubit sessionCubit) {
|
static GoRouter createRouter(SessionCubit sessionCubit) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
|
navigatorKey: rootNavigatorKey,
|
||||||
initialLocation: '/',
|
initialLocation: '/',
|
||||||
refreshListenable: GoRouterRefreshStream(sessionCubit.stream),
|
refreshListenable: GoRouterRefreshStream(sessionCubit.stream),
|
||||||
|
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final sessionState = sessionCubit.state;
|
final sessionState = sessionCubit.state;
|
||||||
final isGoingToLogin = state.matchedLocation == '/login';
|
final isGoingToLogin = state.matchedLocation == '/login';
|
||||||
@@ -132,7 +151,37 @@ class AppRouter {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
name: Routes.home,
|
name: Routes.home,
|
||||||
builder: (context, state) => const HomeScreen(),
|
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(
|
||||||
|
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: const HomeScreen(),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -171,7 +220,11 @@ class AppRouter {
|
|||||||
path:
|
path:
|
||||||
'stores', // Sistemata l'inversione path/name -> /master-data/stores
|
'stores', // Sistemata l'inversione path/name -> /master-data/stores
|
||||||
name: Routes.stores,
|
name: Routes.stores,
|
||||||
builder: (context, state) => const StoresScreen(),
|
builder: (context, state) {
|
||||||
|
context.read<ProviderListCubit>().loadAllProviders();
|
||||||
|
context.read<StoreCubit>().loadStores();
|
||||||
|
return const StoresScreen();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'company-settings', // -> /master-data/company-settings
|
path: 'company-settings', // -> /master-data/company-settings
|
||||||
@@ -197,6 +250,16 @@ class AppRouter {
|
|||||||
name: Routes.themeSettings,
|
name: Routes.themeSettings,
|
||||||
builder: (context, state) => const ThemeSettingsView(),
|
builder: (context, state) => const ThemeSettingsView(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'reminderSettings',
|
||||||
|
name: Routes.reminderSettings,
|
||||||
|
builder: (context, state) =>
|
||||||
|
BlocProvider<ReminderDefaultsCubit>(
|
||||||
|
create: (context) => ReminderDefaultsCubit(),
|
||||||
|
|
||||||
|
child: const ReminderSettingsScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -219,6 +282,34 @@ class AppRouter {
|
|||||||
name: Routes.notes,
|
name: Routes.notes,
|
||||||
builder: (context, state) => const NotesListScreen(),
|
builder: (context, state) => const NotesListScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/tasks',
|
||||||
|
name: Routes.tasks,
|
||||||
|
builder: (context, state) {
|
||||||
|
// 1. Recuperiamo lo stato della sessione per le dipendenze
|
||||||
|
final sessionState = context.read<SessionCubit>().state;
|
||||||
|
|
||||||
|
// Sicurezza: Se per qualche motivo non abbiamo l'azienda,
|
||||||
|
// qui potresti reindirizzare o gestire l'errore
|
||||||
|
final companyId = sessionState.company?.id;
|
||||||
|
if (companyId == null) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: Text("Errore: Azienda non trovata")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Iniettiamo il Cubit con tutto ciò che gli serve
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) => TaskListCubit(
|
||||||
|
currentCompanyId: companyId,
|
||||||
|
currentStoreId: sessionState
|
||||||
|
.currentStore
|
||||||
|
?.id, // Opzionale: filtra per negozio se l'utente è "dentro" uno store
|
||||||
|
),
|
||||||
|
child: const TaskListScreen(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -477,6 +568,44 @@ class AppRouter {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/tasks/form/:id',
|
||||||
|
name: Routes.taskForm,
|
||||||
|
builder: (context, state) {
|
||||||
|
final String pathId = state.pathParameters['id'] ?? 'new';
|
||||||
|
final TaskModel? task = state.extra as TaskModel?;
|
||||||
|
final String? realTaskId;
|
||||||
|
if (pathId == 'new') {
|
||||||
|
realTaskId = null;
|
||||||
|
} else if (task?.id != null) {
|
||||||
|
realTaskId = task!.id;
|
||||||
|
} else {
|
||||||
|
realTaskId = pathId;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
existingTask: task,
|
||||||
|
initialTaskId: realTaskId,
|
||||||
|
allStaff: preloadedStaff,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
child: TaskFormScreen(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,7 @@ class Routes {
|
|||||||
static const String ticketWorkspace = 'ticket-workspace';
|
static const String ticketWorkspace = 'ticket-workspace';
|
||||||
static const String noteForm = 'note-form';
|
static const String noteForm = 'note-form';
|
||||||
static const String notes = 'notes';
|
static const String notes = 'notes';
|
||||||
|
static const String tasks = 'tasks';
|
||||||
|
static const String taskForm = 'task-form';
|
||||||
|
static const String reminderSettings = 'reminder-settings';
|
||||||
}
|
}
|
||||||
|
|||||||
36
lib/core/services/notification_service.dart
Normal file
36
lib/core/services/notification_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,67 +1,94 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'dart:io' show Platform;
|
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
class VersionCheckService {
|
class VersionCheckService {
|
||||||
final _supabase = Supabase.instance.client;
|
|
||||||
|
|
||||||
/// Controlla se l'app corrente deve essere bloccata o aggiornata.
|
|
||||||
/// Ritorna il link di download se l'aggiornamento è obbligatorio, altrimenti null.
|
|
||||||
Future<String?> checkForceUpdate() async {
|
Future<String?> checkForceUpdate() async {
|
||||||
try {
|
try {
|
||||||
// 1. Determiniamo la piattaforma corrente
|
// 1. Capiamo su che piattaforma sta girando l'app in questo istante
|
||||||
String platformKey = 'web';
|
String currentPlatform = _getCurrentPlatform();
|
||||||
if (!kIsWeb) {
|
|
||||||
if (Platform.isAndroid) platformKey = 'android';
|
// 2. Recuperiamo SOLO la riga corrispondente alla nostra piattaforma
|
||||||
if (Platform.isWindows) platformKey = 'windows';
|
final dbResponse = await Supabase.instance.client
|
||||||
|
.from('app_config')
|
||||||
|
.select('min_version, download_url')
|
||||||
|
.eq('platform', currentPlatform)
|
||||||
|
.maybeSingle(); // Usiamo maybeSingle così se non c'è la riga non crasha
|
||||||
|
|
||||||
|
if (dbResponse == null) {
|
||||||
|
return null; // Nessuna regola per questa piattaforma
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Recuperiamo la configurazione minima da Supabase
|
String minVersionFromDb = dbResponse['min_version'] as String;
|
||||||
final data = await _supabase
|
String downloadUrl = dbResponse['download_url'] as String;
|
||||||
.from('app_config')
|
|
||||||
.select()
|
|
||||||
.eq('platform', platformKey)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (data == null) return null;
|
// 3. Recuperiamo la versione locale di Flutter
|
||||||
|
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
String localVersionRaw = packageInfo.version;
|
||||||
|
|
||||||
final String minVersion = data['min_version'];
|
// 🥷 TRUCCO 1: Pulizia totale dai build number (+37) o tag "v"
|
||||||
final String downloadUrl = data['download_url'];
|
String cleanLocal = localVersionRaw
|
||||||
|
.split('+')
|
||||||
|
.first
|
||||||
|
.replaceAll('v', '')
|
||||||
|
.trim();
|
||||||
|
String cleanDb = minVersionFromDb
|
||||||
|
.split('+')
|
||||||
|
.first
|
||||||
|
.replaceAll('v', '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
// 3. Recuperiamo la versione attuale dell'app dal pubspec.yaml
|
// 🥷 TRUCCO 2: Confronto Semantico Reale
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
if (_isVersionLower(current: cleanLocal, minimum: cleanDb)) {
|
||||||
final String currentVersion = packageInfo.version;
|
// Ritorna il link VERO per questa specifica piattaforma preso dal CSV!
|
||||||
|
return downloadUrl;
|
||||||
// 4. Confronto matematico semantico (es. 1.2.3 vs 1.1.9)
|
|
||||||
if (_isVersionLower(currentVersion, minVersion)) {
|
|
||||||
return downloadUrl; // Aggiornamento obbligatorio richiesto!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Errore controllo versione: $e');
|
debugPrint("Errore durante il check versione: $e");
|
||||||
return null; // In caso di errore non blocchiamo l'utente
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isVersionLower(String current, String min) {
|
// Helper ninja per mappare le piattaforme in base alle stringhe del tuo DB
|
||||||
|
String _getCurrentPlatform() {
|
||||||
|
if (kIsWeb) return 'web';
|
||||||
|
if (Platform.isAndroid) return 'android';
|
||||||
|
if (Platform.isIOS) return 'ios';
|
||||||
|
if (Platform.isWindows) return 'windows';
|
||||||
|
if (Platform.isMacOS) return 'macos';
|
||||||
|
if (Platform.isLinux) return 'linux';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Il motore matematico (resta invariato)
|
||||||
|
bool _isVersionLower({required String current, required String minimum}) {
|
||||||
|
if (current == minimum) return false;
|
||||||
|
|
||||||
List<int> currentParts = current
|
List<int> currentParts = current
|
||||||
.split('.')
|
.split('.')
|
||||||
.map((e) => int.tryParse(e) ?? 0)
|
.map((e) => int.tryParse(e) ?? 0)
|
||||||
.toList();
|
.toList();
|
||||||
List<int> minParts = min
|
List<int> minParts = minimum
|
||||||
.split('.')
|
.split('.')
|
||||||
.map((e) => int.tryParse(e) ?? 0)
|
.map((e) => int.tryParse(e) ?? 0)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
for (int i = 0; i < 3; i++) {
|
while (currentParts.length < 3) {
|
||||||
int currentPart = currentParts.length > i ? currentParts[i] : 0;
|
currentParts.add(0);
|
||||||
int minPart = minParts.length > i ? minParts[i] : 0;
|
|
||||||
|
|
||||||
if (currentPart < minPart) return true;
|
|
||||||
if (currentPart > minPart) return false;
|
|
||||||
}
|
}
|
||||||
return false;
|
while (minParts.length < 3) {
|
||||||
|
minParts.add(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentParts[0] != minParts[0]) {
|
||||||
|
return currentParts[0] < minParts[0];
|
||||||
|
}
|
||||||
|
if (currentParts[1] != minParts[1]) {
|
||||||
|
return currentParts[1] < minParts[1];
|
||||||
|
}
|
||||||
|
return currentParts[2] < minParts[2];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class FluxTextField extends StatefulWidget {
|
|||||||
final TextCapitalization? textCapitalization;
|
final TextCapitalization? textCapitalization;
|
||||||
final bool? autocorrect;
|
final bool? autocorrect;
|
||||||
final bool? enabled;
|
final bool? enabled;
|
||||||
|
final Iterable<String>? autofillHints;
|
||||||
|
|
||||||
const FluxTextField({
|
const FluxTextField({
|
||||||
super.key, // Usiamo super.key per Flutter moderno
|
super.key, // Usiamo super.key per Flutter moderno
|
||||||
@@ -41,6 +42,7 @@ class FluxTextField extends StatefulWidget {
|
|||||||
this.textCapitalization,
|
this.textCapitalization,
|
||||||
this.autocorrect,
|
this.autocorrect,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
|
this.autofillHints,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -118,6 +120,7 @@ class _FluxTextFieldState extends State<FluxTextField> {
|
|||||||
|
|
||||||
textCapitalization: widget.textCapitalization ?? TextCapitalization.none,
|
textCapitalization: widget.textCapitalization ?? TextCapitalization.none,
|
||||||
enabled: widget.enabled,
|
enabled: widget.enabled,
|
||||||
|
autofillHints: widget.autofillHints,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,14 +34,14 @@ class SharedAttachmentsSection extends StatefulWidget {
|
|||||||
final String? parentId;
|
final String? parentId;
|
||||||
final String titleForUpload;
|
final String titleForUpload;
|
||||||
final AttachmentParentType parentType;
|
final AttachmentParentType parentType;
|
||||||
final Future<String?> Function()? onGenerateIdForQr;
|
final Future<String?> Function()? onEnsureEntitySaved;
|
||||||
|
|
||||||
const SharedAttachmentsSection({
|
const SharedAttachmentsSection({
|
||||||
super.key,
|
super.key,
|
||||||
this.parentId,
|
this.parentId,
|
||||||
this.titleForUpload = 'Cliente_sconosciuto',
|
this.titleForUpload = 'Cliente_sconosciuto',
|
||||||
required this.parentType,
|
required this.parentType,
|
||||||
this.onGenerateIdForQr,
|
this.onEnsureEntitySaved,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -90,6 +90,32 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
|
|
||||||
// --- SELEZIONE FILE DAL PC/TELEFONO ---
|
// --- SELEZIONE FILE DAL PC/TELEFONO ---
|
||||||
Future<void> _pickFiles() async {
|
Future<void> _pickFiles() async {
|
||||||
|
final attachmentsBloc = context.read<AttachmentsBloc>();
|
||||||
|
String? targetId = attachmentsBloc.state.parentId;
|
||||||
|
|
||||||
|
// 🥷 SE L'ID NON C'È (Nuova Operazione), FORZIAMO IL SALVATAGGIO PREVENTIVO!
|
||||||
|
if (targetId == null || targetId.isEmpty) {
|
||||||
|
if (widget.onEnsureEntitySaved != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Salvataggio rapido scheda per allegati... ⏳'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Chiamiamo la funzione passata dal TicketForm/OperationForm
|
||||||
|
targetId = await widget.onEnsureEntitySaved!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se il salvataggio fallisce (es. form non valido), ci fermiamo per evitare file orfani
|
||||||
|
if (targetId == null || targetId.isEmpty) return;
|
||||||
|
|
||||||
|
// Comunichiamo immediatamente al BLoC che l'entità padre è stata salvata e ha un nuovo ID.
|
||||||
|
// Questo eviterà che i file finiscano nei `localFiles` temporanei.
|
||||||
|
attachmentsBloc.add(ParentEntitySavedEvent(targetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ora che abbiamo la certezza matematica di avere un targetId, apriamo il picker
|
||||||
final result = await FilePicker.pickFiles(
|
final result = await FilePicker.pickFiles(
|
||||||
allowMultiple: true,
|
allowMultiple: true,
|
||||||
type: FileType.custom,
|
type: FileType.custom,
|
||||||
@@ -98,8 +124,8 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result != null && mounted) {
|
if (result != null && mounted) {
|
||||||
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
|
// Ora il BLoC eseguirà l'ambiente di "Upload immediato" (Bivio 2) perché ha l'ID aggiornato!
|
||||||
context.read<AttachmentsBloc>().add(AddAttachmentsEvent(result.files));
|
attachmentsBloc.add(AddAttachmentsEvent(result.files));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +507,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
|
|
||||||
// SE L'ID NON C'È, CHIAMIAMO IL SALVATAGGIO IN BACKGROUND!
|
// SE L'ID NON C'È, CHIAMIAMO IL SALVATAGGIO IN BACKGROUND!
|
||||||
if (targetId == null) {
|
if (targetId == null) {
|
||||||
if (widget.onGenerateIdForQr != null) {
|
if (widget.onEnsureEntitySaved != null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
@@ -492,7 +518,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Aspettiamo che il TicketFormCubit faccia il suo lavoro
|
// Aspettiamo che il TicketFormCubit faccia il suo lavoro
|
||||||
targetId = await widget.onGenerateIdForQr!();
|
targetId = await widget.onEnsureEntitySaved!();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se fallisce (es. validazione form non passata), ci fermiamo
|
// Se fallisce (es. validazione form non passata), ci fermiamo
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
@@ -14,7 +16,6 @@ part 'attachments_state.dart';
|
|||||||
|
|
||||||
class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
||||||
final _repository = GetIt.I.get<AttachmentsRepository>();
|
final _repository = GetIt.I.get<AttachmentsRepository>();
|
||||||
final String? companyId = GetIt.I.get<SessionCubit>().state.company?.id;
|
|
||||||
|
|
||||||
AttachmentsBloc({String? parentId, required AttachmentParentType parentType})
|
AttachmentsBloc({String? parentId, required AttachmentParentType parentType})
|
||||||
: super(
|
: super(
|
||||||
@@ -36,8 +37,8 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
on<SelectAllAttachmentsEvent>(_onSelectAllAttachments);
|
on<SelectAllAttachmentsEvent>(_onSelectAllAttachments);
|
||||||
on<ClearAttachmentSelectionEvent>(_onClearAttachmentSelection);
|
on<ClearAttachmentSelectionEvent>(_onClearAttachmentSelection);
|
||||||
|
|
||||||
// Se il BLoC nasce già con un ID, carichiamo i file
|
final currentCompanyId = GetIt.I.get<SessionCubit>().state.company?.id;
|
||||||
if (parentId != null && companyId != null) {
|
if (parentId != null && currentCompanyId != null) {
|
||||||
add(LoadAttachmentsEvent(parentId: parentId));
|
add(LoadAttachmentsEvent(parentId: parentId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,6 +47,8 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
ParentEntitySavedEvent event,
|
ParentEntitySavedEvent event,
|
||||||
Emitter<AttachmentsState> emit,
|
Emitter<AttachmentsState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
final companyId = GetIt.I.get<SessionCubit>().state.company?.id;
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
parentId: event.newParentId,
|
parentId: event.newParentId,
|
||||||
@@ -117,14 +120,30 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
Emitter<AttachmentsState> emit,
|
Emitter<AttachmentsState> emit,
|
||||||
) async {
|
) async {
|
||||||
final currentId = state.parentId;
|
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) {
|
if (currentId == null) {
|
||||||
final newLocalFiles = event.files.map((file) {
|
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(
|
return AttachmentModel(
|
||||||
id: null,
|
id: null,
|
||||||
companyId: companyId!,
|
companyId: currentCompanyId,
|
||||||
operationId: state.parentType == AttachmentParentType.operation
|
operationId: state.parentType == AttachmentParentType.operation
|
||||||
? ''
|
? ''
|
||||||
: null,
|
: null,
|
||||||
@@ -136,7 +155,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
extension: file.name.fileExtension(),
|
extension: file.name.fileExtension(),
|
||||||
storagePath: '',
|
storagePath: '',
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
localBytes: file.bytes,
|
localBytes: rawBytes, // Ora i byte ci sono al 100% anche su Mac!
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@@ -157,7 +176,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
|||||||
parentId: currentId,
|
parentId: currentId,
|
||||||
parentType: state.parentType,
|
parentType: state.parentType,
|
||||||
pickedFile: file,
|
pickedFile: file,
|
||||||
companyId: companyId!,
|
companyId: currentCompanyId,
|
||||||
bucket: _getBucketForParentType,
|
bucket: _getBucketForParentType,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ enum Bucket {
|
|||||||
|
|
||||||
class AttachmentsRepository {
|
class AttachmentsRepository {
|
||||||
final _supabase = Supabase.instance.client;
|
final _supabase = Supabase.instance.client;
|
||||||
static const String _tableName = Tables.attachments;
|
|
||||||
|
|
||||||
/// Scarica i byte di un file direttamente da Supabase Storage
|
/// Scarica i byte di un file direttamente da Supabase Storage
|
||||||
Future<Uint8List> downloadAttachmentBytes({
|
Future<Uint8List> downloadAttachmentBytes({
|
||||||
@@ -56,7 +55,7 @@ class AttachmentsRepository {
|
|||||||
final columnName = _getColumnNameForParent(parentType);
|
final columnName = _getColumnNameForParent(parentType);
|
||||||
|
|
||||||
return _supabase
|
return _supabase
|
||||||
.from(_tableName)
|
.from(Tables.attachments)
|
||||||
.stream(primaryKey: ['id'])
|
.stream(primaryKey: ['id'])
|
||||||
.eq(columnName, parentId)
|
.eq(columnName, parentId)
|
||||||
.map(
|
.map(
|
||||||
@@ -141,7 +140,7 @@ class AttachmentsRepository {
|
|||||||
insertData[columnName] = parentId;
|
insertData[columnName] = parentId;
|
||||||
|
|
||||||
// 6. Salviamo su Postgres
|
// 6. Salviamo su Postgres
|
||||||
await _supabase.from(_tableName).insert(insertData);
|
await _supabase.from(Tables.attachments).insert(insertData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception("Errore caricamento: $e");
|
throw Exception("Errore caricamento: $e");
|
||||||
}
|
}
|
||||||
@@ -179,12 +178,12 @@ class AttachmentsRepository {
|
|||||||
// A. Ci sono ancora altre entità che usano questo file!
|
// A. Ci sono ancora altre entità che usano questo file!
|
||||||
// Scolleghiamolo SOLO dal contesto attuale mettendo a NULL la sua colonna
|
// Scolleghiamolo SOLO dal contesto attuale mettendo a NULL la sua colonna
|
||||||
await _supabase
|
await _supabase
|
||||||
.from(_tableName)
|
.from(Tables.attachments)
|
||||||
.update({currentContextType.dbColumn: null})
|
.update({currentContextType.dbColumn: null})
|
||||||
.eq('id', file.id!);
|
.eq('id', file.id!);
|
||||||
} else {
|
} else {
|
||||||
// B. Nessuno usa più questo file! ELIMINAZIONE FISICA TOTALE.
|
// B. Nessuno usa più questo file! ELIMINAZIONE FISICA TOTALE.
|
||||||
await _supabase.from(_tableName).delete().eq('id', file.id!);
|
await _supabase.from(Tables.attachments).delete().eq('id', file.id!);
|
||||||
|
|
||||||
if (file.storagePath != null) {
|
if (file.storagePath != null) {
|
||||||
await _supabase.storage.from(bucket.value).remove([
|
await _supabase.storage.from(bucket.value).remove([
|
||||||
@@ -202,7 +201,7 @@ class AttachmentsRepository {
|
|||||||
Future<void> renameAttachment(String fileId, String newName) async {
|
Future<void> renameAttachment(String fileId, String newName) async {
|
||||||
try {
|
try {
|
||||||
await _supabase
|
await _supabase
|
||||||
.from(_tableName)
|
.from(Tables.attachments)
|
||||||
.update({'name': newName})
|
.update({'name': newName})
|
||||||
.eq('id', fileId);
|
.eq('id', fileId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -219,7 +218,7 @@ class AttachmentsRepository {
|
|||||||
try {
|
try {
|
||||||
// Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta
|
// Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta
|
||||||
await _supabase
|
await _supabase
|
||||||
.from(_tableName)
|
.from(Tables.attachments)
|
||||||
.update({targetType.dbColumn: targetId})
|
.update({targetType.dbColumn: targetId})
|
||||||
.eq('id', fileId);
|
.eq('id', fileId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.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/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:get_it/get_it.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
part 'auth_state.dart';
|
part 'auth_state.dart';
|
||||||
|
|
||||||
class AuthCubit extends Cubit<AuthState> {
|
class AuthCubit extends Cubit<AuthState> {
|
||||||
final _supabase = GetIt.instance<SupabaseClient>();
|
final _supabase = GetIt.instance<SupabaseClient>();
|
||||||
|
final _staffRepository = GetIt.instance<StaffRepository>();
|
||||||
|
|
||||||
AuthCubit() : super(const AuthState());
|
AuthCubit() : super(const AuthState());
|
||||||
|
|
||||||
@@ -16,7 +17,8 @@ class AuthCubit extends Cubit<AuthState> {
|
|||||||
emit(state.copyWith(isLoginMode: !state.isLoginMode));
|
emit(state.copyWith(isLoginMode: !state.isLoginMode));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> submitAuth(String email, String password) async {
|
Future<bool> submitAuth(String email, String password) async {
|
||||||
|
// <-- Modificato in bool
|
||||||
// Partiamo puliti: via vecchi messaggi ed errori
|
// Partiamo puliti: via vecchi messaggi ed errori
|
||||||
emit(state.copyWith(status: AuthStatus.loading));
|
emit(state.copyWith(status: AuthStatus.loading));
|
||||||
|
|
||||||
@@ -27,9 +29,17 @@ class AuthCubit extends Cubit<AuthState> {
|
|||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
);
|
);
|
||||||
// NESSUN EMIT DI SUCCESS!
|
|
||||||
// Supabase lancerà l'evento 'signedIn', il SessionCubit lo catturerà
|
// Il login è andato a buon fine!
|
||||||
// e il GoRouter ci cambierà pagina. Noi stiamo a guardare il caricamento.
|
emit(
|
||||||
|
AuthState(
|
||||||
|
status: AuthStatus.initial,
|
||||||
|
isLoginMode: true,
|
||||||
|
errorMessage: null,
|
||||||
|
infoMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
} else {
|
} else {
|
||||||
// --- LOGICA SIGNUP ---
|
// --- LOGICA SIGNUP ---
|
||||||
final AuthResponse res = await _supabase.auth.signUp(
|
final AuthResponse res = await _supabase.auth.signUp(
|
||||||
@@ -38,7 +48,6 @@ class AuthCubit extends Cubit<AuthState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (res.session == null) {
|
if (res.session == null) {
|
||||||
// Caso: Conferma Email attivata su Supabase
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: AuthStatus.initial,
|
status: AuthStatus.initial,
|
||||||
@@ -48,16 +57,24 @@ class AuthCubit extends Cubit<AuthState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Caso: Autologin post-registrazione (Conferma email disattivata)
|
|
||||||
// 1. Fermiamo il frullino!
|
|
||||||
emit(state.copyWith(status: AuthStatus.initial));
|
emit(state.copyWith(status: AuthStatus.initial));
|
||||||
// 2. Svegliamo il SessionCubit!
|
|
||||||
GetIt.I<SessionCubit>().initializeSession();
|
GetIt.I<SessionCubit>().initializeSession();
|
||||||
}
|
}
|
||||||
// Se non è null, ha fatto il login automatico. Stessa cosa di sopra, ci pensa il SessionCubit.
|
|
||||||
|
// Anche la registrazione è andata a buon fine!
|
||||||
|
emit(
|
||||||
|
AuthState(
|
||||||
|
status: AuthStatus.initial,
|
||||||
|
isLoginMode: true,
|
||||||
|
errorMessage: null,
|
||||||
|
infoMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
} on AuthException catch (e) {
|
} on AuthException catch (e) {
|
||||||
emit(state.copyWith(status: AuthStatus.failure, errorMessage: e.message));
|
emit(state.copyWith(status: AuthStatus.failure, errorMessage: e.message));
|
||||||
|
return false; // <-- Il login è fallito
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -65,6 +82,7 @@ class AuthCubit extends Cubit<AuthState> {
|
|||||||
errorMessage: "Errore imprevisto: $e",
|
errorMessage: "Errore imprevisto: $e",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return false; // <-- Il login è fallito
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +96,7 @@ class AuthCubit extends Cubit<AuthState> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _supabase.auth.resetPasswordForEmail(
|
await _staffRepository.resetPassword(email);
|
||||||
email,
|
|
||||||
redirectTo: resetPasswordUrl,
|
|
||||||
);
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: AuthStatus.pwResetSent,
|
status: AuthStatus.pwResetSent,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/theme/theme.dart';
|
import 'package:flux/core/theme/theme.dart';
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
@@ -24,14 +25,18 @@ class _AuthScreenState extends State<AuthScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _submit() {
|
void _submit() async {
|
||||||
// Chiudiamo la tastiera per fare pulizia a schermo
|
// Chiudiamo la tastiera per fare pulizia a schermo
|
||||||
FocusScope.of(context).unfocus();
|
FocusScope.of(context).unfocus();
|
||||||
|
|
||||||
context.read<AuthCubit>().submitAuth(
|
final isSuccess = await context.read<AuthCubit>().submitAuth(
|
||||||
_emailController.text.trim(),
|
_emailController.text.trim(),
|
||||||
_passwordController.text.trim(),
|
_passwordController.text.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -69,125 +74,157 @@ class _AuthScreenState extends State<AuthScreen> {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
child: Column(
|
child: AutofillGroup(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Column(
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
// --- LOGO FLUX ---
|
children: [
|
||||||
const FluxLogoAuto(height: 80),
|
// --- LOGO FLUX ---
|
||||||
const SizedBox(height: 60),
|
const FluxLogoAuto(height: 80),
|
||||||
|
const SizedBox(height: 60),
|
||||||
|
|
||||||
// --- TITOLO DINAMICO ---
|
// --- TITOLO DINAMICO ---
|
||||||
Text(
|
Text(
|
||||||
state.isLoginMode
|
state.isLoginMode
|
||||||
? context.l10n.authScreenWelcomeBack
|
? context.l10n.authScreenWelcomeBack
|
||||||
: context.l10n.authScreenCreateAccount,
|
: context.l10n.authScreenCreateAccount,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: context.primaryText,
|
color: context.primaryText,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
letterSpacing: 1.5,
|
letterSpacing: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 8),
|
Text(
|
||||||
Text(
|
state.isLoginMode
|
||||||
state.isLoginMode
|
? context.l10n.authScreenLoginToManageYourBusiness
|
||||||
? context.l10n.authScreenLoginToManageYourBusiness
|
: context
|
||||||
: context
|
.l10n
|
||||||
.l10n
|
.authScreenStartTodayToDigitalizeYourStore,
|
||||||
.authScreenStartTodayToDigitalizeYourStore,
|
textAlign: TextAlign.center,
|
||||||
textAlign: TextAlign.center,
|
style: TextStyle(color: context.secondaryText),
|
||||||
style: TextStyle(color: context.secondaryText),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
|
|
||||||
// --- CAMPI INPUT ---
|
|
||||||
FluxTextField(
|
|
||||||
label: context.l10n.authScreenBusinessEmail,
|
|
||||||
icon: Icons.email_outlined,
|
|
||||||
controller: _emailController,
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
FluxTextField(
|
|
||||||
label: 'Password',
|
|
||||||
icon: Icons.lock_outline,
|
|
||||||
isPassword: true, // Magia del FluxTextField!
|
|
||||||
controller: _passwordController,
|
|
||||||
onSubmitted: (_) =>
|
|
||||||
_submit(), // Se lo supporti nel tuo widget custom
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
|
|
||||||
// --- BOTTONE PRINCIPALE ---
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 56,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: isLoading ? null : _submit,
|
|
||||||
child: isLoading
|
|
||||||
? const SizedBox(
|
|
||||||
height: 24,
|
|
||||||
width: 24,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Text(
|
|
||||||
state.isLoginMode
|
|
||||||
? context.l10n.authScreenLogin
|
|
||||||
: context.l10n.authScreenSignUp,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
// --- SWITCH LOGIN/SIGNUP ---
|
// --- CAMPI INPUT ---
|
||||||
const SizedBox(height: 24),
|
FluxTextField(
|
||||||
TextButton(
|
label: context.l10n.authScreenBusinessEmail,
|
||||||
onPressed: isLoading
|
icon: Icons.email_outlined,
|
||||||
? null
|
controller: _emailController,
|
||||||
: () => context.read<AuthCubit>().toggleMode(),
|
keyboardType: TextInputType.emailAddress,
|
||||||
child: RichText(
|
autofillHints: const [
|
||||||
text: TextSpan(
|
AutofillHints.email,
|
||||||
text: state.isLoginMode
|
AutofillHints.username,
|
||||||
? context.l10n.authScreenDontHaveAccount
|
],
|
||||||
: context.l10n.authScreenAlreadyHaveAccount,
|
),
|
||||||
style: TextStyle(color: context.secondaryText),
|
const SizedBox(height: 20),
|
||||||
children: [
|
FluxTextField(
|
||||||
TextSpan(
|
label: 'Password',
|
||||||
text: state.isLoginMode
|
icon: Icons.lock_outline,
|
||||||
? context.l10n.authScreenSignUp
|
isPassword: true, // Magia del FluxTextField!
|
||||||
: context.l10n.authScreenLogin,
|
controller: _passwordController,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
onSubmitted: (_) =>
|
||||||
|
_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(
|
style: TextStyle(
|
||||||
color: context.accent,
|
color: context.accent,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
|
// --- BOTTONE PRINCIPALE ---
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 56,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: isLoading ? null : _submit,
|
||||||
|
child: isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 24,
|
||||||
|
width: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
state.isLoginMode
|
||||||
|
? context.l10n.authScreenLogin
|
||||||
|
: context.l10n.authScreenSignUp,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (state.isLoginMode) ...[
|
// --- SWITCH LOGIN/SIGNUP ---
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context
|
onPressed: isLoading
|
||||||
.read<AuthCubit>()
|
? null
|
||||||
.requestPasswordReset(_emailController.text.trim()),
|
: () => context.read<AuthCubit>().toggleMode(),
|
||||||
child: Text(
|
child: RichText(
|
||||||
context.l10n.authScreenForgotPassword,
|
text: TextSpan(
|
||||||
style: TextStyle(
|
text: state.isLoginMode
|
||||||
color: context.accent,
|
? context.l10n.authScreenDontHaveAccount
|
||||||
fontWeight: FontWeight.bold,
|
: context.l10n.authScreenAlreadyHaveAccount,
|
||||||
|
style: TextStyle(color: context.secondaryText),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: state.isLoginMode
|
||||||
|
? context.l10n.authScreenSignUp
|
||||||
|
: context.l10n.authScreenLogin,
|
||||||
|
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.accent,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (state.isLoginMode) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
context.read<AuthCubit>().requestPasswordReset(
|
||||||
|
_emailController.text.trim(),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.authScreenForgotPassword,
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.accent,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
255
lib/features/auth/ui/set_password_screen.dart
Normal file
255
lib/features/auth/ui/set_password_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/routes/routes.dart';
|
import 'package:flux/core/routes/routes.dart';
|
||||||
import 'package:flux/core/theme/theme.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:flux/features/notes/models/note_model.dart';
|
||||||
import 'package:go_router/go_router.dart'; // Supponendo tu usi GoRouter per la navigazione
|
import 'package:go_router/go_router.dart'; // Supponendo tu usi GoRouter per la navigazione
|
||||||
|
|
||||||
class DashboardNotesWidget extends StatelessWidget {
|
class DashboardNoteListCard extends StatelessWidget {
|
||||||
const DashboardNotesWidget({super.key});
|
const DashboardNoteListCard({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -59,14 +59,14 @@ class DashboardNotesWidget extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Il corpo del widget collegato al Bloc
|
// Il corpo del widget collegato al Bloc
|
||||||
BlocBuilder<NotesBloc, NotesState>(
|
BlocBuilder<DashboardNoteListCubit, DashboardNoteListState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.status == NotesStatus.loading &&
|
if (state.status == DashboardNoteListStatus.loading &&
|
||||||
state.notes.isEmpty) {
|
state.notes.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.status == NotesStatus.failure) {
|
if (state.status == DashboardNoteListStatus.failure) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Errore nel caricamento delle note.',
|
'Errore nel caricamento delle note.',
|
||||||
@@ -105,7 +105,11 @@ class DashboardNotesWidget extends StatelessWidget {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Vai al form di dettaglio passando l'ID o l'oggetto
|
// Vai al form di dettaglio passando l'ID o l'oggetto
|
||||||
context.push('/notes/edit/${note.id}');
|
context.pushNamed(
|
||||||
|
Routes.noteForm,
|
||||||
|
pathParameters: {'id': note.id!},
|
||||||
|
extra: note,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 140,
|
width: 140,
|
||||||
@@ -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 paginatedData = await _repository.fetchPaginatedOperations(
|
||||||
|
companyId: companyId!,
|
||||||
|
storeId: storeId!,
|
||||||
|
page: 1,
|
||||||
|
itemsPerPage: 20,
|
||||||
|
);
|
||||||
|
if (isClosed) return;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: DashboardStoreOperationListStatus.success,
|
||||||
|
operations: paginatedData.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
|
||||||
import 'package:flux/core/routes/routes.dart';
|
import 'package:flux/core/routes/routes.dart';
|
||||||
import 'package:flux/core/theme/theme.dart';
|
import 'package:flux/core/theme/theme.dart';
|
||||||
import 'package:flux/core/utils/extensions.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';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class LatestStoreOperationsCard extends StatelessWidget {
|
class DashboardStoreOperationListCard extends StatelessWidget {
|
||||||
const LatestStoreOperationsCard({super.key});
|
const DashboardStoreOperationListCard({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final currentStoreId = context.read<SessionCubit>().state.currentStore?.id;
|
return _LatestOperationsCardContent();
|
||||||
|
|
||||||
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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,21 +70,21 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child:
|
child:
|
||||||
BlocBuilder<
|
BlocBuilder<
|
||||||
LatestStoreOperationsBloc,
|
DashboardStoreOperationListCubit,
|
||||||
LatestStoreOperationsState
|
DashboardStoreOperationListState
|
||||||
>(
|
>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.status ==
|
if (state.status ==
|
||||||
LatestStoreOperationsStatus.loading ||
|
DashboardStoreOperationListStatus.loading ||
|
||||||
state.status ==
|
state.status ==
|
||||||
LatestStoreOperationsStatus.initial) {
|
DashboardStoreOperationListStatus.initial) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: CircularProgressIndicator(),
|
child: CircularProgressIndicator(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.status ==
|
if (state.status ==
|
||||||
LatestStoreOperationsStatus.failure) {
|
DashboardStoreOperationListStatus.failure) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
"Errore di caricamento",
|
"Errore di caricamento",
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.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';
|
||||||
|
|
||||||
|
part 'dashboard_task_list_state.dart';
|
||||||
|
|
||||||
|
class DashboardTaskListCubit extends Cubit<DashboardTaskListState> {
|
||||||
|
final TasksRepository _repository = GetIt.I.get<TasksRepository>();
|
||||||
|
final String? staffId;
|
||||||
|
final String? companyId;
|
||||||
|
StreamSubscription<void>? _tasksSubscription;
|
||||||
|
|
||||||
|
DashboardTaskListCubit({required this.staffId, required this.companyId})
|
||||||
|
: super(const DashboardTaskListState());
|
||||||
|
|
||||||
|
void startListening() {
|
||||||
|
stopListening();
|
||||||
|
|
||||||
|
emit(state.copyWith(status: DashboardTaskListStatus.loading));
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopListening() {
|
||||||
|
_tasksSubscription?.cancel();
|
||||||
|
_tasksSubscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTasksSilently() async {
|
||||||
|
try {
|
||||||
|
final tasks = await _repository.getTasks(
|
||||||
|
companyId: companyId!,
|
||||||
|
staffId: staffId,
|
||||||
|
statuses: [TaskStatus.open, TaskStatus.inProgress],
|
||||||
|
limit: 10,
|
||||||
|
);
|
||||||
|
if (isClosed) return;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: DashboardTaskListStatus.success,
|
||||||
|
tasks: tasks,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (isClosed) return;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: DashboardTaskListStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
stopListening();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
part of 'dashboard_task_list_cubit.dart';
|
||||||
|
|
||||||
|
enum DashboardTaskListStatus { initial, loading, success, failure }
|
||||||
|
|
||||||
|
class DashboardTaskListState extends Equatable {
|
||||||
|
final DashboardTaskListStatus status;
|
||||||
|
final List<TaskModel> tasks;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const DashboardTaskListState({
|
||||||
|
this.status = DashboardTaskListStatus.initial,
|
||||||
|
this.tasks = const [],
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
DashboardTaskListState copyWith({
|
||||||
|
DashboardTaskListStatus? status,
|
||||||
|
List<TaskModel>? tasks,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return DashboardTaskListState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
tasks: tasks ?? this.tasks,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [status, tasks, errorMessage];
|
||||||
|
}
|
||||||
@@ -1,46 +1,28 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
|
||||||
import 'package:flux/core/routes/routes.dart';
|
import 'package:flux/core/routes/routes.dart';
|
||||||
import 'package:flux/core/theme/theme.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/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
|
||||||
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_status.dart';
|
||||||
|
|
||||||
class LatestStoreTicketsCard extends StatelessWidget {
|
class DashboardTaskListCard extends StatelessWidget {
|
||||||
const LatestStoreTicketsCard({super.key});
|
const DashboardTaskListCard({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final currentStoreId = context.read<SessionCubit>().state.currentStore?.id;
|
return _DashboardTasksCardContent();
|
||||||
|
|
||||||
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 {
|
class _DashboardTasksCardContent extends StatelessWidget {
|
||||||
|
const _DashboardTasksCardContent();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
const color = Colors.blue;
|
const color =
|
||||||
|
Colors.orange; // Colore arancione per distinguerla dai Ticket blu
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -49,7 +31,9 @@ class _LatestStoreTicketsCardContent extends StatelessWidget {
|
|||||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => context.pushNamed(Routes.tickets),
|
onTap: () => context.pushNamed(
|
||||||
|
Routes.tasks,
|
||||||
|
), // Porta alla lista completa (TaskListScreen)
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -65,7 +49,7 @@ class _LatestStoreTicketsCardContent extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.design_services_outlined,
|
Icons.assignment_outlined, // Icona a tema ToDo
|
||||||
color: color,
|
color: color,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
@@ -73,7 +57,7 @@ class _LatestStoreTicketsCardContent extends StatelessWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
"Ticket recenti",
|
"I Miei Task",
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -89,26 +73,26 @@ class _LatestStoreTicketsCardContent extends StatelessWidget {
|
|||||||
|
|
||||||
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
|
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
|
||||||
Expanded(
|
Expanded(
|
||||||
child: BlocBuilder<LatestStoreTicketsBloc, LatestStoreTicketsState>(
|
child: BlocBuilder<DashboardTaskListCubit, DashboardTaskListState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.status == LatestStoreTicketsStatus.loading ||
|
if (state.status == DashboardTaskListStatus.loading ||
|
||||||
state.status == LatestStoreTicketsStatus.initial) {
|
state.status == DashboardTaskListStatus.initial) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.status == LatestStoreTicketsStatus.failure) {
|
if (state.status == DashboardTaskListStatus.failure) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
"Errore di caricamento",
|
"Errore di caricamento ${state.errorMessage}",
|
||||||
style: TextStyle(color: theme.colorScheme.error),
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.tickets.isEmpty) {
|
if (state.tasks.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
"Nessun ticket recente.",
|
"Nessun task in sospeso. Ottimo lavoro!",
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: context.secondaryText,
|
color: context.secondaryText,
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
@@ -118,19 +102,35 @@ class _LatestStoreTicketsCardContent extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
itemCount: state.tickets.length,
|
itemCount: state.tasks.length,
|
||||||
separatorBuilder: (context, index) => Divider(
|
separatorBuilder: (context, index) => Divider(
|
||||||
height: 1,
|
height: 1,
|
||||||
color: theme.dividerColor.withValues(alpha: 0.3),
|
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final ticket = state.tickets[index];
|
final task = state.tasks[index];
|
||||||
final statusColor = ticket.ticketStatus.color;
|
|
||||||
|
// Definisci il colore in base allo stato del task
|
||||||
|
final statusColor = task.status == TaskStatus.inProgress
|
||||||
|
? Colors.blue
|
||||||
|
: Colors.grey.shade400;
|
||||||
|
|
||||||
|
// Formattiamo la data (o indichiamo se non c'è)
|
||||||
|
final dueDateString = task.dueDate != null
|
||||||
|
? "${task.dueDate!.day}/${task.dueDate!.month}"
|
||||||
|
: "Nessuna";
|
||||||
|
|
||||||
|
// Controllo Ninja: Il task è già scaduto rispetto a oggi?
|
||||||
|
final isOverdue =
|
||||||
|
task.dueDate != null &&
|
||||||
|
task.dueDate!.isBefore(DateTime.now());
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => context.pushNamed(
|
onTap: () => context.pushNamed(
|
||||||
Routes.ticketForm,
|
Routes.taskForm,
|
||||||
extra: (createdBy: null, ticket: ticket),
|
extra:
|
||||||
pathParameters: {'id': ticket.id!},
|
task, // Passiamo direttamente il modello intero se il tuo router lo accetta!
|
||||||
|
pathParameters: {'id': task.id ?? 'new'},
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
@@ -139,32 +139,17 @@ class _LatestStoreTicketsCardContent extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 8,
|
width: 8,
|
||||||
height:
|
height: 30,
|
||||||
30, // Un'altezza fissa per farlo comparire!
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: statusColor,
|
color: statusColor,
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(4),
|
||||||
4,
|
|
||||||
), // Angoli smussati per stile
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 5,
|
flex: 7,
|
||||||
child: Text(
|
child: Text(
|
||||||
ticket.customer?.name ??
|
task.title,
|
||||||
'Cliente sconosciuto',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: context.primaryText,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
flex: 5,
|
|
||||||
child: Text(
|
|
||||||
ticket.targetModelName ??
|
|
||||||
'Modello sconosciuto',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: context.primaryText,
|
color: context.primaryText,
|
||||||
@@ -173,11 +158,22 @@ class _LatestStoreTicketsCardContent extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Expanded(
|
||||||
"${ticket.createdAt?.day}/${ticket.createdAt?.month}",
|
flex: 3,
|
||||||
style: TextStyle(
|
child: Align(
|
||||||
color: context.secondaryText,
|
alignment: Alignment.centerRight,
|
||||||
fontSize: 12,
|
child: Text(
|
||||||
|
dueDateString,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isOverdue
|
||||||
|
? theme.colorScheme.error
|
||||||
|
: context.secondaryText,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isOverdue
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// TODO: implement event handlers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,94 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.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/routes/routes.dart';
|
||||||
import 'package:flux/core/theme/theme.dart';
|
import 'package:flux/core/theme/theme.dart';
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
import 'package:flux/core/widgets/staff_selector_modal.dart';
|
import 'package:flux/core/widgets/staff_selector_modal.dart';
|
||||||
import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart';
|
import 'package:flux/features/home/dashboard_note_list/blocs/dashboard_note_list_cubit.dart';
|
||||||
import 'package:flux/features/home/latest_store_tickets/ui/latest_store_tickets_card.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/home/ui/quick_actions_widget.dart';
|
||||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.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/master_data/staff/models/staff_member_model.dart';
|
||||||
import 'package:flux/features/notes/data/notes_repository.dart';
|
import 'package:flux/features/notes/data/notes_repository.dart';
|
||||||
import 'package:flux/features/notes/models/note_model.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:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -66,21 +136,16 @@ class HomeScreen extends StatelessWidget {
|
|||||||
childAspectRatio: 1.3,
|
childAspectRatio: 1.3,
|
||||||
),
|
),
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildListDelegate([
|
||||||
LatestStoreOperationsCard(),
|
DashboardStoreOperationListCard(),
|
||||||
LatestStoreTicketsCard(),
|
DashboardStoreTicketListCard(),
|
||||||
_buildDashboardWidget(
|
_buildDashboardWidget(
|
||||||
title: context.l10n.homeExpiringContracts,
|
title: context.l10n.homeExpiringContracts,
|
||||||
icon: Icons.assignment_late_outlined,
|
icon: Icons.assignment_late_outlined,
|
||||||
color: Colors.orange,
|
color: Colors.orange,
|
||||||
context: context,
|
context: context,
|
||||||
),
|
),
|
||||||
DashboardNotesWidget(),
|
DashboardNoteListCard(),
|
||||||
_buildDashboardWidget(
|
DashboardTaskListCard(),
|
||||||
title: context.l10n.homeMyTasks,
|
|
||||||
icon: Icons.check_box_outlined,
|
|
||||||
color: Colors.green,
|
|
||||||
context: context,
|
|
||||||
),
|
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -97,9 +162,6 @@ class HomeScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// WIDGET BUILDERS
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext context, ThemeData theme) {
|
Widget _buildHeader(BuildContext context, ThemeData theme) {
|
||||||
final user = context.watch<SessionCubit>().state.currentStaffMember;
|
final user = context.watch<SessionCubit>().state.currentStaffMember;
|
||||||
final currentStore = context.watch<SessionCubit>().state.currentStore;
|
final currentStore = context.watch<SessionCubit>().state.currentStore;
|
||||||
@@ -236,9 +298,9 @@ class HomeScreen extends StatelessWidget {
|
|||||||
QuickActionButton(
|
QuickActionButton(
|
||||||
icon: Icons.task_alt,
|
icon: Icons.task_alt,
|
||||||
label: context.l10n.commonTask,
|
label: context.l10n.commonTask,
|
||||||
color: Colors.teal,
|
color: Colors.orange,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// TODO: Quando faremo i task
|
context.pushNamed(Routes.taskForm, pathParameters: {'id': 'new'});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart'; // Per estrarre gli store
|
import 'package:supabase_flutter/supabase_flutter.dart'; // Per estrarre gli store
|
||||||
import '../models/provider_model.dart';
|
import '../models/provider_model.dart';
|
||||||
@@ -32,7 +33,7 @@ class ProviderFormCubit extends Cubit<ProviderFormState> {
|
|||||||
try {
|
try {
|
||||||
// 1. Scarichiamo tutti i negozi dell'azienda
|
// 1. Scarichiamo tutti i negozi dell'azienda
|
||||||
final storesResponse = await _client
|
final storesResponse = await _client
|
||||||
.from('store')
|
.from(Tables.stores)
|
||||||
.select('id, name')
|
.select('id, name')
|
||||||
.eq('company_id', companyId);
|
.eq('company_id', companyId);
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ class ProviderFormCubit extends Cubit<ProviderFormState> {
|
|||||||
if (existingProvider != null && existingProvider.id != null) {
|
if (existingProvider != null && existingProvider.id != null) {
|
||||||
// ... (Vecchio codice di recupero)
|
// ... (Vecchio codice di recupero)
|
||||||
final links = await _client
|
final links = await _client
|
||||||
.from('providers_in_stores')
|
.from(Tables.providersInStores)
|
||||||
.select('store_id')
|
.select('store_id')
|
||||||
.eq('provider_id', existingProvider.id!);
|
.eq('provider_id', existingProvider.id!);
|
||||||
linkedStoreIds = (links as List)
|
linkedStoreIds = (links as List)
|
||||||
@@ -83,6 +84,7 @@ class ProviderFormCubit extends Cubit<ProviderFormState> {
|
|||||||
String? fiscalCode,
|
String? fiscalCode,
|
||||||
String? sdiCode,
|
String? sdiCode,
|
||||||
String? emailPec,
|
String? emailPec,
|
||||||
|
String? Function()? colorHex,
|
||||||
}) {
|
}) {
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -93,6 +95,7 @@ class ProviderFormCubit extends Cubit<ProviderFormState> {
|
|||||||
fiscalCode: fiscalCode,
|
fiscalCode: fiscalCode,
|
||||||
sdiCode: sdiCode,
|
sdiCode: sdiCode,
|
||||||
emailPec: emailPec,
|
emailPec: emailPec,
|
||||||
|
colorHex: colorHex,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'provider_location_model.dart';
|
import 'provider_location_model.dart';
|
||||||
import 'provider_role.dart';
|
import 'provider_role.dart';
|
||||||
@@ -8,6 +9,7 @@ class ProviderModel extends Equatable {
|
|||||||
final String companyId;
|
final String companyId;
|
||||||
final String name; // Nome "commerciale" per riconoscerlo velocemente
|
final String name; // Nome "commerciale" per riconoscerlo velocemente
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
|
final String? colorHex;
|
||||||
|
|
||||||
// Dati fiscali e legali
|
// Dati fiscali e legali
|
||||||
final String? businessName; // Ragione Sociale
|
final String? businessName; // Ragione Sociale
|
||||||
@@ -29,6 +31,7 @@ class ProviderModel extends Equatable {
|
|||||||
required this.companyId,
|
required this.companyId,
|
||||||
required this.name,
|
required this.name,
|
||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
|
this.colorHex,
|
||||||
this.businessName,
|
this.businessName,
|
||||||
this.vatNumber,
|
this.vatNumber,
|
||||||
this.fiscalCode,
|
this.fiscalCode,
|
||||||
@@ -42,6 +45,17 @@ class ProviderModel extends Equatable {
|
|||||||
this.locations,
|
this.locations,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🥷 IL GETTER MAGICO: Converte l'esadecimale in un Color di Flutter
|
||||||
|
Color get displayColor {
|
||||||
|
if (colorHex == null || colorHex!.isEmpty) {
|
||||||
|
return Colors.blueGrey; // Colore di default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rimuove l'eventuale '#' e aggiunge 'FF' per l'opacità (Alpha)
|
||||||
|
final hex = colorHex!.replaceAll('#', '');
|
||||||
|
return Color(int.parse('FF$hex', radix: 16));
|
||||||
|
}
|
||||||
|
|
||||||
factory ProviderModel.empty({required String companyId}) {
|
factory ProviderModel.empty({required String companyId}) {
|
||||||
return ProviderModel(
|
return ProviderModel(
|
||||||
companyId: companyId,
|
companyId: companyId,
|
||||||
@@ -56,6 +70,7 @@ class ProviderModel extends Equatable {
|
|||||||
String? companyId,
|
String? companyId,
|
||||||
String? name,
|
String? name,
|
||||||
bool? isActive,
|
bool? isActive,
|
||||||
|
String? Function()? colorHex,
|
||||||
String? businessName,
|
String? businessName,
|
||||||
String? vatNumber,
|
String? vatNumber,
|
||||||
String? fiscalCode,
|
String? fiscalCode,
|
||||||
@@ -73,6 +88,7 @@ class ProviderModel extends Equatable {
|
|||||||
companyId: companyId ?? this.companyId,
|
companyId: companyId ?? this.companyId,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
isActive: isActive ?? this.isActive,
|
isActive: isActive ?? this.isActive,
|
||||||
|
colorHex: colorHex != null ? colorHex() : this.colorHex,
|
||||||
businessName: businessName ?? this.businessName,
|
businessName: businessName ?? this.businessName,
|
||||||
vatNumber: vatNumber ?? this.vatNumber,
|
vatNumber: vatNumber ?? this.vatNumber,
|
||||||
fiscalCode: fiscalCode ?? this.fiscalCode,
|
fiscalCode: fiscalCode ?? this.fiscalCode,
|
||||||
@@ -114,6 +130,7 @@ class ProviderModel extends Equatable {
|
|||||||
companyId: map['company_id'] as String,
|
companyId: map['company_id'] as String,
|
||||||
name: map['name'] as String,
|
name: map['name'] as String,
|
||||||
isActive: map['is_active'] as bool? ?? true,
|
isActive: map['is_active'] as bool? ?? true,
|
||||||
|
colorHex: map['color_hex'] as String?,
|
||||||
businessName: map['business_name'] as String?,
|
businessName: map['business_name'] as String?,
|
||||||
vatNumber: map['vat_number'] as String?,
|
vatNumber: map['vat_number'] as String?,
|
||||||
fiscalCode: map['fiscal_code'] as String?,
|
fiscalCode: map['fiscal_code'] as String?,
|
||||||
@@ -134,6 +151,7 @@ class ProviderModel extends Equatable {
|
|||||||
'company_id': companyId,
|
'company_id': companyId,
|
||||||
'name': name,
|
'name': name,
|
||||||
'is_active': isActive,
|
'is_active': isActive,
|
||||||
|
'color_hex': colorHex,
|
||||||
'business_name': businessName,
|
'business_name': businessName,
|
||||||
'vat_number': vatNumber,
|
'vat_number': vatNumber,
|
||||||
'fiscal_code': fiscalCode,
|
'fiscal_code': fiscalCode,
|
||||||
@@ -155,6 +173,7 @@ class ProviderModel extends Equatable {
|
|||||||
companyId,
|
companyId,
|
||||||
name,
|
name,
|
||||||
isActive,
|
isActive,
|
||||||
|
colorHex,
|
||||||
businessName,
|
businessName,
|
||||||
vatNumber,
|
vatNumber,
|
||||||
fiscalCode,
|
fiscalCode,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,17 @@ class _ProviderFormScreenState extends State<ProviderFormScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final List<String> _brandColors = [
|
||||||
|
'#E60000', // Vodafone/Iliad (Rosso scuro)
|
||||||
|
'#0047BB', // TIM (Blu)
|
||||||
|
'#F4811F', // WINDTRE (Arancione)
|
||||||
|
'#FFCC00', // Fastweb (Giallo)
|
||||||
|
'#00A859', // Verde generico
|
||||||
|
'#8E44AD', // Viola
|
||||||
|
'#2C3E50', // Blu scuro/Nero
|
||||||
|
'#607D8B', // BlueGrey (Default)
|
||||||
|
];
|
||||||
|
|
||||||
void _flushControllers() {
|
void _flushControllers() {
|
||||||
context.read<ProviderFormCubit>().updateFields(
|
context.read<ProviderFormCubit>().updateFields(
|
||||||
name: _nameCtrl.text.trim(),
|
name: _nameCtrl.text.trim(),
|
||||||
@@ -132,6 +143,8 @@ class _ProviderFormScreenState extends State<ProviderFormScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_buildGeneralCard(context, state),
|
_buildGeneralCard(context, state),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
_buildColorPicker(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
_buildRolesCard(context, state),
|
_buildRolesCard(context, state),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildFiscalCard(context),
|
_buildFiscalCard(context),
|
||||||
@@ -392,4 +405,70 @@ class _ProviderFormScreenState extends State<ProviderFormScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildColorPicker() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Colore Riconoscitivo',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
BlocBuilder<ProviderFormCubit, ProviderFormState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
// Se non ha un colore, usiamo il BlueGrey di default
|
||||||
|
final currentColorHex = state.provider.colorHex ?? '#607D8B';
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: _brandColors.map((hexCode) {
|
||||||
|
final isSelected =
|
||||||
|
currentColorHex.toUpperCase() == hexCode.toUpperCase();
|
||||||
|
|
||||||
|
// Conversione rapida per disegnare il cerchio
|
||||||
|
final colorValue = Color(
|
||||||
|
int.parse('FF${hexCode.replaceAll('#', '')}', radix: 16),
|
||||||
|
);
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
onTap: () {
|
||||||
|
// Aggiorniamo il Cubit con il nuovo colore
|
||||||
|
context.read<ProviderFormCubit>().updateFields(
|
||||||
|
colorHex: () => hexCode,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorValue,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? Colors.black : Colors.transparent,
|
||||||
|
width: isSelected ? 3 : 0,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
if (isSelected)
|
||||||
|
BoxShadow(
|
||||||
|
color: colorValue.withValues(alpha: 0.4),
|
||||||
|
blurRadius: 8,
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: isSelected
|
||||||
|
? const Icon(Icons.check, color: Colors.white, size: 24)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,22 @@ class StaffCubit extends Cubit<StaffState> {
|
|||||||
final StaffRepository _repository = GetIt.I.get<StaffRepository>();
|
final StaffRepository _repository = GetIt.I.get<StaffRepository>();
|
||||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||||
|
|
||||||
StaffCubit() : super(const StaffState());
|
StaffCubit() : super(const StaffState()) {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
emit(state.copyWith(status: StaffStatus.loading, error: null));
|
||||||
|
try {
|
||||||
|
final allStaff = await _repository.getStaffMembers(
|
||||||
|
_sessionCubit.state.company!.id!,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(state.copyWith(status: StaffStatus.success, allStaff: allStaff));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Carica tutto lo staff della compagnia
|
// Carica tutto lo staff della compagnia
|
||||||
Future<void> loadAllStaff() async {
|
Future<void> loadAllStaff() async {
|
||||||
@@ -102,9 +117,9 @@ class StaffCubit extends Cubit<StaffState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resetPasswordOrResendInviteLink(String email) async {
|
Future<void> resetPassword(String email) async {
|
||||||
try {
|
try {
|
||||||
await _repository.resetPasswordOrResendInviteLink(email);
|
await _repository.resetPassword(email);
|
||||||
emit(state.copyWith(status: StaffStatus.emailSent, error: null));
|
emit(state.copyWith(status: StaffStatus.emailSent, error: null));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
|
emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flux/core/enums_and_consts/consts.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/staff/models/staff_member_model.dart';
|
||||||
import 'package:flux/features/master_data/store/models/store_model.dart';
|
import 'package:flux/features/master_data/store/models/store_model.dart';
|
||||||
@@ -10,21 +11,50 @@ class StaffRepository {
|
|||||||
// --- ANAGRAFICA PURA ---
|
// --- ANAGRAFICA PURA ---
|
||||||
|
|
||||||
// Prende tutto lo staff della Company (per l'Hub Anagrafiche)
|
// Prende tutto lo staff della Company (per l'Hub Anagrafiche)
|
||||||
Future<List<StaffMemberModel>> getStaffMembers(String companyId) async {
|
Future<List<StaffMemberModel>> getStaffMembers(
|
||||||
final response = await _supabase
|
String companyId, {
|
||||||
.from(Tables.staffMembers)
|
String? storeId,
|
||||||
.select()
|
}) async {
|
||||||
.eq('company_id', companyId)
|
try {
|
||||||
.order('name', ascending: true);
|
var filterBuilder = _supabase
|
||||||
|
.from(Tables.staffMembers)
|
||||||
|
.select('''
|
||||||
|
*,
|
||||||
|
store_assignments:${Tables.staffInStores} (
|
||||||
|
${Tables.stores}(*)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
.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 {
|
Future<StaffMemberModel?> getStaffMemberById(String staffId) async {
|
||||||
try {
|
try {
|
||||||
final response = await _supabase
|
final response = await _supabase
|
||||||
.from(Tables.staffMembers)
|
.from(Tables.staffMembers)
|
||||||
.select()
|
.select('''
|
||||||
|
*,
|
||||||
|
store_assignments:${Tables.staffInStores} (
|
||||||
|
${Tables.stores}(*)
|
||||||
|
)
|
||||||
|
''')
|
||||||
.eq('id', staffId)
|
.eq('id', staffId)
|
||||||
.single();
|
.single();
|
||||||
return StaffMemberModel.fromMap(response);
|
return StaffMemberModel.fromMap(response);
|
||||||
@@ -74,12 +104,16 @@ class StaffRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resetPasswordOrResendInviteLink(String email) async {
|
Future<void> resetPassword(String email) async {
|
||||||
try {
|
try {
|
||||||
await _supabase.auth.resetPasswordForEmail(
|
final response = await Supabase.instance.client.functions.invoke(
|
||||||
email,
|
'reset_password',
|
||||||
redirectTo: resetPasswordUrl,
|
body: {'email': email.trim()},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (response.status != 200) {
|
||||||
|
throw Exception(response.data['error'] ?? "Errore sconosciuto");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception("Errore nell'invio del link: $e");
|
throw Exception("Errore nell'invio del link: $e");
|
||||||
}
|
}
|
||||||
@@ -119,10 +153,10 @@ class StaffRepository {
|
|||||||
|
|
||||||
// Assegna un membro a un negozio
|
// Assegna un membro a un negozio
|
||||||
Future<void> assignStaffToStore(String staffId, String storeId) async {
|
Future<void> assignStaffToStore(String staffId, String storeId) async {
|
||||||
await _supabase.from(Tables.staffInStores).insert({
|
await _supabase.from(Tables.staffInStores).upsert({
|
||||||
'staff_member_id': staffId,
|
'staff_member_id': staffId,
|
||||||
'store_id': storeId,
|
'store_id': storeId,
|
||||||
});
|
}, onConflict: 'staff_member_id,store_id'); // Evita duplicati
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rimuove l'assegnazione
|
// Rimuove l'assegnazione
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
|
import 'package:flux/features/master_data/store/models/store_model.dart';
|
||||||
|
|
||||||
// L'Enum magico e blindato per il sistema
|
// L'Enum magico e blindato per il sistema
|
||||||
enum SystemRole {
|
enum SystemRole {
|
||||||
@@ -26,6 +28,8 @@ class StaffMemberModel extends Equatable {
|
|||||||
final SystemRole systemRole;
|
final SystemRole systemRole;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
final bool hasJoined;
|
final bool hasJoined;
|
||||||
|
final List<String> assignedStoreIds;
|
||||||
|
final List<StoreModel> assignedStores;
|
||||||
|
|
||||||
const StaffMemberModel({
|
const StaffMemberModel({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -38,6 +42,8 @@ class StaffMemberModel extends Equatable {
|
|||||||
this.systemRole = SystemRole.user,
|
this.systemRole = SystemRole.user,
|
||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
this.hasJoined = false,
|
this.hasJoined = false,
|
||||||
|
this.assignedStoreIds = const [],
|
||||||
|
this.assignedStores = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
StaffMemberModel copyWith({
|
StaffMemberModel copyWith({
|
||||||
@@ -52,6 +58,8 @@ class StaffMemberModel extends Equatable {
|
|||||||
SystemRole? systemRole,
|
SystemRole? systemRole,
|
||||||
bool? isActive,
|
bool? isActive,
|
||||||
bool? hasJoined,
|
bool? hasJoined,
|
||||||
|
List<String>? assignedStoreIds,
|
||||||
|
List<StoreModel>? assignedStores,
|
||||||
}) {
|
}) {
|
||||||
return StaffMemberModel(
|
return StaffMemberModel(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -64,6 +72,8 @@ class StaffMemberModel extends Equatable {
|
|||||||
systemRole: systemRole ?? this.systemRole,
|
systemRole: systemRole ?? this.systemRole,
|
||||||
isActive: isActive ?? this.isActive,
|
isActive: isActive ?? this.isActive,
|
||||||
hasJoined: hasJoined ?? this.hasJoined,
|
hasJoined: hasJoined ?? this.hasJoined,
|
||||||
|
assignedStoreIds: assignedStoreIds ?? this.assignedStoreIds,
|
||||||
|
assignedStores: assignedStores ?? this.assignedStores,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +89,24 @@ class StaffMemberModel extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
factory StaffMemberModel.fromMap(Map<String, dynamic> map) {
|
factory StaffMemberModel.fromMap(Map<String, dynamic> map) {
|
||||||
|
// 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota
|
||||||
|
final List<String> parsedAssignedStoreIds =
|
||||||
|
map['assigned_store_ids'] != null
|
||||||
|
? List<String>.from(map['assigned_store_ids'])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 2. Mappiamo il JOIN degli store, se presente
|
||||||
|
List<StoreModel> storeList = [];
|
||||||
|
|
||||||
|
// Gestione del JSON proveniente dal Join nidificato (es. task_assignments -> staff_members)
|
||||||
|
if (map['store_assignments'] != null) {
|
||||||
|
storeList = (map['store_assignments'] as List)
|
||||||
|
.map((a) => a[Tables.stores])
|
||||||
|
.where((s) => s != null)
|
||||||
|
.map((s) => StoreModel.fromMap(s))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
return StaffMemberModel(
|
return StaffMemberModel(
|
||||||
id: map['id'] as String?,
|
id: map['id'] as String?,
|
||||||
companyId: map['company_id'] ?? '',
|
companyId: map['company_id'] ?? '',
|
||||||
@@ -90,6 +118,8 @@ class StaffMemberModel extends Equatable {
|
|||||||
systemRole: SystemRole.fromString(map['system_role']),
|
systemRole: SystemRole.fromString(map['system_role']),
|
||||||
isActive: map['is_active'] ?? true,
|
isActive: map['is_active'] ?? true,
|
||||||
hasJoined: map['has_joined'] ?? false,
|
hasJoined: map['has_joined'] ?? false,
|
||||||
|
assignedStoreIds: parsedAssignedStoreIds,
|
||||||
|
assignedStores: storeList,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,5 +150,7 @@ class StaffMemberModel extends Equatable {
|
|||||||
systemRole,
|
systemRole,
|
||||||
isActive,
|
isActive,
|
||||||
hasJoined,
|
hasJoined,
|
||||||
|
assignedStoreIds,
|
||||||
|
assignedStores,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/core/theme/theme.dart';
|
import 'package:flux/core/theme/theme.dart';
|
||||||
import 'package:flux/core/widgets/flux_text_field.dart';
|
import 'package:flux/core/widgets/flux_text_field.dart';
|
||||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; // Tuo percorso
|
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||||
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
|
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
@@ -17,18 +17,16 @@ class StaffScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _StaffScreenState extends State<StaffScreen> {
|
class _StaffScreenState extends State<StaffScreen> {
|
||||||
String? _selectedStoreId;
|
String? _selectedStoreId;
|
||||||
bool _showAllCompanyStaff = true; // Partiamo con la vista globale
|
bool _showAllCompanyStaff = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Carichiamo subito tutto
|
|
||||||
context.read<StaffCubit>().loadAllStaff();
|
context.read<StaffCubit>().loadAllStaff();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// 1. Peschiamo chi siamo noi e che poteri abbiamo
|
|
||||||
final myRole = context
|
final myRole = context
|
||||||
.read<SessionCubit>()
|
.read<SessionCubit>()
|
||||||
.state
|
.state
|
||||||
@@ -36,12 +34,12 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
?.systemRole;
|
?.systemRole;
|
||||||
final canManageStaff =
|
final canManageStaff =
|
||||||
myRole == SystemRole.admin || myRole == SystemRole.manager;
|
myRole == SystemRole.admin || myRole == SystemRole.manager;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: context.background,
|
backgroundColor: context.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("Anagrafica Personale"),
|
title: const Text("Anagrafica Personale"),
|
||||||
actions: [
|
actions: [
|
||||||
// Toggle per vista Azienda / Negozio
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 16),
|
padding: const EdgeInsets.only(right: 16),
|
||||||
child: FilterChip(
|
child: FilterChip(
|
||||||
@@ -66,7 +64,7 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// --- BARRA FILTRO NEGOZIO (Visibile solo se non 'Tutta l'Azienda') ---
|
// BARRA FILTRO NEGOZIO
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
height: _showAllCompanyStaff ? 0 : 80,
|
height: _showAllCompanyStaff ? 0 : 80,
|
||||||
@@ -75,7 +73,7 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
: _buildStoreSelector(),
|
: _buildStoreSelector(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- LISTA PERSONALE ---
|
// LISTA PERSONALE
|
||||||
Expanded(
|
Expanded(
|
||||||
child: BlocBuilder<StaffCubit, StaffState>(
|
child: BlocBuilder<StaffCubit, StaffState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@@ -87,17 +85,14 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (list.isEmpty) {
|
if (list.isEmpty) return _buildEmptyState();
|
||||||
return _buildEmptyState();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: list.length,
|
itemCount: list.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
separatorBuilder: (_, _) => const SizedBox(height: 6),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final member = list[index];
|
return _buildStaffCard(list[index]);
|
||||||
return _buildStaffCard(member);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -118,7 +113,6 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
|
|
||||||
Widget _buildStoreSelector() {
|
Widget _buildStoreSelector() {
|
||||||
return BlocBuilder<StoreCubit, StoreState>(
|
return BlocBuilder<StoreCubit, StoreState>(
|
||||||
// Assumendo tu abbia uno StoreCubit
|
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
@@ -146,6 +140,8 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
?.systemRole;
|
?.systemRole;
|
||||||
final canManageStaff =
|
final canManageStaff =
|
||||||
myRole == SystemRole.admin || myRole == SystemRole.manager;
|
myRole == SystemRole.admin || myRole == SystemRole.manager;
|
||||||
|
final hasEmail = member.email != null && member.email!.trim().isNotEmpty;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -156,7 +152,10 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
contentPadding: const EdgeInsets.all(12),
|
contentPadding: const EdgeInsets.all(12),
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: context.accent.withValues(alpha: 0.1),
|
backgroundColor: context.accent.withValues(alpha: 0.1),
|
||||||
child: Text(member.name[0], style: TextStyle(color: context.accent)),
|
child: Text(
|
||||||
|
member.name[0].toUpperCase(),
|
||||||
|
style: TextStyle(color: context.accent),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
member.name,
|
member.name,
|
||||||
@@ -165,55 +164,72 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (member.email != null && member.email!.isNotEmpty)
|
if (hasEmail) Text(member.email!),
|
||||||
Text(member.email!),
|
|
||||||
Text(
|
Text(
|
||||||
member.phoneNumber != null && member.phoneNumber!.isNotEmpty
|
member.phoneNumber != null &&
|
||||||
|
member.phoneNumber!.trim().isNotEmpty
|
||||||
? member.phoneNumber!
|
? member.phoneNumber!
|
||||||
: "Nessun telefono",
|
: "Nessun telefono",
|
||||||
),
|
),
|
||||||
],
|
if (member.jobTitle != null &&
|
||||||
),
|
member.jobTitle!.trim().isNotEmpty) ...[
|
||||||
trailing: Row(
|
const SizedBox(height: 4),
|
||||||
mainAxisSize: MainAxisSize.min,
|
Text(
|
||||||
children: [
|
'Qualifica: ${member.jobTitle!}',
|
||||||
if (member.jobTitle != null && member.jobTitle!.isNotEmpty) ...[
|
style: TextStyle(
|
||||||
Text('Qualifica: ${member.jobTitle!}'),
|
color: context.accent,
|
||||||
const SizedBox(width: 8),
|
fontWeight: FontWeight.w500,
|
||||||
],
|
|
||||||
|
|
||||||
if (canManageStaff) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
|
|
||||||
if (!member.hasJoined)
|
|
||||||
ElevatedButton.icon(
|
|
||||||
icon: const Icon(Icons.send),
|
|
||||||
label: const Text("Re-invia Invito (In Attesa)"),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.orange,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
// Chiama la funzione di reset password mascherata da invito
|
|
||||||
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
|
|
||||||
member.email!,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else
|
|
||||||
OutlinedButton.icon(
|
|
||||||
icon: const Icon(Icons.lock_reset),
|
|
||||||
label: const Text("Invia Reset Password"),
|
|
||||||
onPressed: () {
|
|
||||||
// Chiama LA STESSA IDENTICA FUNZIONE!
|
|
||||||
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
|
|
||||||
member.email!,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
// MODIFICA UX: Menu a tendina per le azioni (Salva spazio e previene overflow)
|
||||||
|
trailing: canManageStaff && hasEmail
|
||||||
|
? PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'invite_reset') {
|
||||||
|
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(
|
||||||
|
'Invito reinviato, controlla l\'email!',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
context.read<StaffCubit>().resetPassword(member.email!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'invite_reset',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
!member.hasJoined ? Icons.send : Icons.lock_reset,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
!member.hasJoined
|
||||||
|
? "Re-invia Invito"
|
||||||
|
: "Reset Password",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: null,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
canManageStaff ? _openStaffForm(context, member: member) : null,
|
canManageStaff ? _openStaffForm(context, member: member) : null,
|
||||||
),
|
),
|
||||||
@@ -226,7 +242,6 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
final phoneController = TextEditingController(text: member?.phoneNumber);
|
final phoneController = TextEditingController(text: member?.phoneNumber);
|
||||||
final jobTitleController = TextEditingController(text: member?.jobTitle);
|
final jobTitleController = TextEditingController(text: member?.jobTitle);
|
||||||
|
|
||||||
// Variabili di stato per il BottomSheet
|
|
||||||
SystemRole selectedRole = member?.systemRole ?? SystemRole.user;
|
SystemRole selectedRole = member?.systemRole ?? SystemRole.user;
|
||||||
List<String> tempSelectedStores =
|
List<String> tempSelectedStores =
|
||||||
context
|
context
|
||||||
@@ -263,7 +278,7 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
member == null
|
member == null
|
||||||
? "Invita Collaboratore" // Cambiato il titolo per chiarezza!
|
? "Invita Collaboratore"
|
||||||
: "Modifica Collaboratore",
|
: "Modifica Collaboratore",
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
@@ -279,16 +294,13 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Reso visivamente obbligatorio se è un nuovo utente
|
|
||||||
FluxTextField(
|
FluxTextField(
|
||||||
controller: emailController,
|
controller: emailController,
|
||||||
label: member == null
|
label: member == null
|
||||||
? "Email (Obbligatoria per invito)*"
|
? "Email (Obbligatoria per invito)*"
|
||||||
: "Email",
|
: "Email",
|
||||||
icon: Icons.email,
|
icon: Icons.email,
|
||||||
enabled:
|
enabled: member == null,
|
||||||
member ==
|
|
||||||
null, // UX: Di solito l'email non si cambia dopo l'invito
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -299,7 +311,6 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// --- NOVITÀ: SCELTA DEL RUOLO E MANSIONE ---
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -382,7 +393,6 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
height: 50,
|
height: 50,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// Validazione di base per i nuovi inviti
|
|
||||||
if (member == null &&
|
if (member == null &&
|
||||||
emailController.text.trim().isEmpty) {
|
emailController.text.trim().isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -396,7 +406,7 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final updatedMember = StaffMemberModel(
|
final updatedMember = StaffMemberModel(
|
||||||
id: member?.id, // Sarà null se è nuovo
|
id: member?.id,
|
||||||
name: nameController.text.trim(),
|
name: nameController.text.trim(),
|
||||||
email: emailController.text.trim(),
|
email: emailController.text.trim(),
|
||||||
phoneNumber: phoneController.text.trim(),
|
phoneNumber: phoneController.text.trim(),
|
||||||
@@ -410,17 +420,12 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
userId: GetIt.I.get<SessionCubit>().state.user!.id,
|
userId: GetIt.I.get<SessionCubit>().state.user!.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- IL BIVIO LOGICO MAGICO ---
|
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
// 1. UTENTE NUOVO -> Chiamiamo la Edge Function
|
|
||||||
// (Nota: Per i negozi, potresti dover fare una logica a parte nel Cubit
|
|
||||||
// perché l'ID del database viene generato DOPO che l'Edge Function ha finito)
|
|
||||||
context.read<StaffCubit>().inviteStaffMember(
|
context.read<StaffCubit>().inviteStaffMember(
|
||||||
member: updatedMember,
|
member: updatedMember,
|
||||||
selectedStoreIds: tempSelectedStores,
|
selectedStoreIds: tempSelectedStores,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 2. UTENTE ESISTENTE -> Modifica classica
|
|
||||||
context.read<StaffCubit>().saveStaffWithStores(
|
context.read<StaffCubit>().saveStaffWithStores(
|
||||||
member: updatedMember,
|
member: updatedMember,
|
||||||
selectedStoreIds: tempSelectedStores,
|
selectedStoreIds: tempSelectedStores,
|
||||||
@@ -434,32 +439,6 @@ class _StaffScreenState extends State<StaffScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
/* const SizedBox(height: 16),
|
|
||||||
if (!member!.hasJoined)
|
|
||||||
ElevatedButton.icon(
|
|
||||||
icon: const Icon(Icons.send),
|
|
||||||
label: const Text("Re-invia Invito (In Attesa)"),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.orange,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
// Chiama la funzione di reset password mascherata da invito
|
|
||||||
context
|
|
||||||
.read<StaffCubit>()
|
|
||||||
.resetPasswordOrResendInviteLink(member.email!);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else
|
|
||||||
OutlinedButton.icon(
|
|
||||||
icon: const Icon(Icons.lock_reset),
|
|
||||||
label: const Text("Invia Reset Password"),
|
|
||||||
onPressed: () {
|
|
||||||
// Chiama LA STESSA IDENTICA FUNZIONE!
|
|
||||||
context
|
|
||||||
.read<StaffCubit>()
|
|
||||||
.resetPasswordOrResendInviteLink(member.email!);
|
|
||||||
},
|
|
||||||
), */
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,14 +17,18 @@ class StoreCubit extends Cubit<StoreState> {
|
|||||||
|
|
||||||
StoreCubit() : super(const StoreState(stores: []));
|
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));
|
emit(state.copyWith(status: StoreStatus.loading));
|
||||||
try {
|
try {
|
||||||
await _repository.createStore(store);
|
final savedStore = await _repository.saveStore(store);
|
||||||
emit(state.copyWith(status: StoreStatus.success));
|
emit(state.copyWith(status: StoreStatus.success, savedStore: savedStore));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(
|
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(
|
state.copyWith(
|
||||||
status: StoreStatus.failure,
|
status: StoreStatus.failure,
|
||||||
errorMessage: "Errore nel salvataggio dei provider: $e",
|
errorMessage: "Errore nel salvataggio dei provider: $e",
|
||||||
|
savedStore: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -90,6 +95,7 @@ class StoreCubit extends Cubit<StoreState> {
|
|||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: StoreStatus.failure,
|
status: StoreStatus.failure,
|
||||||
errorMessage: "Errore nel salvataggio dello staff: $e",
|
errorMessage: "Errore nel salvataggio dello staff: $e",
|
||||||
|
savedStore: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,6 +116,7 @@ class StoreCubit extends Cubit<StoreState> {
|
|||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: StoreStatus.failure,
|
status: StoreStatus.failure,
|
||||||
errorMessage: "Errore nell'associazione: $e",
|
errorMessage: "Errore nell'associazione: $e",
|
||||||
|
savedStore: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,6 +137,7 @@ class StoreCubit extends Cubit<StoreState> {
|
|||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: StoreStatus.failure,
|
status: StoreStatus.failure,
|
||||||
errorMessage: "Errore nella rimozione: $e",
|
errorMessage: "Errore nella rimozione: $e",
|
||||||
|
savedStore: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -142,7 +150,11 @@ class StoreCubit extends Cubit<StoreState> {
|
|||||||
loadStores();
|
loadStores();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(
|
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(
|
state.copyWith(
|
||||||
status: StoreStatus.failure,
|
status: StoreStatus.failure,
|
||||||
errorMessage: "Errore nella rimozione: $e",
|
errorMessage: "Errore nella rimozione: $e",
|
||||||
|
savedStore: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class StoreState extends Equatable {
|
|||||||
final StoreModel? store;
|
final StoreModel? store;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final List<StoreModel> stores;
|
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;
|
final Map<String, List<StaffMemberModel>> staffByStore;
|
||||||
|
|
||||||
const StoreState({
|
const StoreState({
|
||||||
@@ -14,6 +16,7 @@ class StoreState extends Equatable {
|
|||||||
this.store,
|
this.store,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
required this.stores,
|
required this.stores,
|
||||||
|
this.savedStore,
|
||||||
this.staffByStore = const {},
|
this.staffByStore = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ class StoreState extends Equatable {
|
|||||||
StoreModel? store,
|
StoreModel? store,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
List<StoreModel>? stores,
|
List<StoreModel>? stores,
|
||||||
|
StoreModel? savedStore,
|
||||||
Map<String, List<StaffMemberModel>>? staffByStore,
|
Map<String, List<StaffMemberModel>>? staffByStore,
|
||||||
}) {
|
}) {
|
||||||
return StoreState(
|
return StoreState(
|
||||||
@@ -29,6 +33,7 @@ class StoreState extends Equatable {
|
|||||||
store: store ?? this.store,
|
store: store ?? this.store,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
stores: stores ?? this.stores,
|
stores: stores ?? this.stores,
|
||||||
|
savedStore: savedStore ?? this.savedStore,
|
||||||
staffByStore: staffByStore ?? this.staffByStore,
|
staffByStore: staffByStore ?? this.staffByStore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -39,6 +44,7 @@ class StoreState extends Equatable {
|
|||||||
store,
|
store,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
stores,
|
stores,
|
||||||
|
savedStore,
|
||||||
staffByStore,
|
staffByStore,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class StoreRepository {
|
|||||||
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
|
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
|
||||||
|
|
||||||
/// Crea un nuovo negozio associato alla compagnia dell'utente
|
/// Crea un nuovo negozio associato alla compagnia dell'utente
|
||||||
Future<void> createStore(StoreModel store) async {
|
/* Future<void> createStore(StoreModel store) async {
|
||||||
try {
|
try {
|
||||||
await _supabase.from(Tables.stores).insert(store.toMap());
|
await _supabase.from(Tables.stores).insert(store.toMap());
|
||||||
} on PostgrestException catch (e) {
|
} on PostgrestException catch (e) {
|
||||||
@@ -18,7 +18,7 @@ class StoreRepository {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw 'Errore imprevisto durante la creazione del negozio: $e';
|
throw 'Errore imprevisto durante la creazione del negozio: $e';
|
||||||
}
|
}
|
||||||
}
|
} */
|
||||||
|
|
||||||
Future<StoreModel> saveStore(StoreModel store) async {
|
Future<StoreModel> saveStore(StoreModel store) async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class StoreModel extends Equatable {
|
|||||||
final List<ProviderModel> associatedProviders; // Provider associati
|
final List<ProviderModel> associatedProviders; // Provider associati
|
||||||
final List<StaffMemberModel>
|
final List<StaffMemberModel>
|
||||||
associatedStaffMembers; // Membri dello staff associati
|
associatedStaffMembers; // Membri dello staff associati
|
||||||
|
final String? defaultProviderId; // ID del provider di default (opzionale)
|
||||||
|
|
||||||
const StoreModel({
|
const StoreModel({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -30,6 +31,7 @@ class StoreModel extends Equatable {
|
|||||||
required this.province,
|
required this.province,
|
||||||
this.associatedProviders = const [],
|
this.associatedProviders = const [],
|
||||||
this.associatedStaffMembers = const [],
|
this.associatedStaffMembers = const [],
|
||||||
|
this.defaultProviderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fondamentale per Equatable: definisce quali proprietà determinano l'uguaglianza
|
// Fondamentale per Equatable: definisce quali proprietà determinano l'uguaglianza
|
||||||
@@ -47,6 +49,7 @@ class StoreModel extends Equatable {
|
|||||||
province,
|
province,
|
||||||
associatedProviders,
|
associatedProviders,
|
||||||
associatedStaffMembers,
|
associatedStaffMembers,
|
||||||
|
defaultProviderId,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Il mitico copyWith per creare nuove istanze modificando solo ciò che serve
|
// Il mitico copyWith per creare nuove istanze modificando solo ciò che serve
|
||||||
@@ -63,6 +66,7 @@ class StoreModel extends Equatable {
|
|||||||
String? province,
|
String? province,
|
||||||
List<ProviderModel>? associatedProviders,
|
List<ProviderModel>? associatedProviders,
|
||||||
List<StaffMemberModel>? associatedStaffMembers,
|
List<StaffMemberModel>? associatedStaffMembers,
|
||||||
|
String? Function()? defaultProviderId,
|
||||||
}) {
|
}) {
|
||||||
return StoreModel(
|
return StoreModel(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -78,6 +82,9 @@ class StoreModel extends Equatable {
|
|||||||
associatedProviders: associatedProviders ?? this.associatedProviders,
|
associatedProviders: associatedProviders ?? this.associatedProviders,
|
||||||
associatedStaffMembers:
|
associatedStaffMembers:
|
||||||
associatedStaffMembers ?? this.associatedStaffMembers,
|
associatedStaffMembers ?? this.associatedStaffMembers,
|
||||||
|
defaultProviderId: defaultProviderId != null
|
||||||
|
? defaultProviderId()
|
||||||
|
: this.defaultProviderId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +138,7 @@ class StoreModel extends Equatable {
|
|||||||
province: map['province'],
|
province: map['province'],
|
||||||
associatedProviders: providers,
|
associatedProviders: providers,
|
||||||
associatedStaffMembers: staffMembers,
|
associatedStaffMembers: staffMembers,
|
||||||
|
defaultProviderId: map['default_provider_id'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +155,7 @@ class StoreModel extends Equatable {
|
|||||||
'zip_code': zipCode,
|
'zip_code': zipCode,
|
||||||
'city': city,
|
'city': city,
|
||||||
'province': province,
|
'province': province,
|
||||||
|
'default_provider_id': defaultProviderId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
|
|||||||
province: _provinciaController.text.trim().toUpperCase(),
|
province: _provinciaController.text.trim().toUpperCase(),
|
||||||
);
|
);
|
||||||
|
|
||||||
context.read<StoreCubit>().createStore(store);
|
context.read<StoreCubit>().saveStore(store);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ class _StoreCardState extends State<StoreCard> {
|
|||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
widget.store.name,
|
widget.store.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
@@ -65,44 +67,43 @@ class _StoreCardState extends State<StoreCard> {
|
|||||||
// context.read<StoreBloc>().add(ToggleStoreStatus(store.id, val));
|
// context.read<StoreBloc>().add(ToggleStoreStatus(store.id, val));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
onTap: () => _openStoreForm(context, store: widget.store),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(8),
|
||||||
child: Row(
|
child: SingleChildScrollView(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
scrollDirection: Axis.horizontal,
|
||||||
children: [
|
child: Row(
|
||||||
// Mostra quanti dipendenti ci sono (usando lo StaffCubit)
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
BlocBuilder<StoreCubit, StoreState>(
|
children: [
|
||||||
builder: (context, storeState) {
|
// Mostra quanti dipendenti ci sono (usando lo StaffCubit)
|
||||||
final staffCount =
|
BlocBuilder<StoreCubit, StoreState>(
|
||||||
storeState.staffByStore[widget.store.id]?.length ?? 0;
|
builder: (context, storeState) {
|
||||||
return Row(
|
final staffCount =
|
||||||
children: [
|
storeState.staffByStore[widget.store.id]?.length ?? 0;
|
||||||
ActionChip(
|
return Row(
|
||||||
avatar: const Icon(Icons.people, size: 16),
|
children: [
|
||||||
label: Text("$staffCount Dipendenti"),
|
ActionChip(
|
||||||
onPressed: () => _manageStoreStaff(widget.store),
|
avatar: const Icon(Icons.people, size: 16),
|
||||||
),
|
label: Text("$staffCount Dipendenti"),
|
||||||
const SizedBox(width: 16),
|
onPressed: () => _manageStoreStaff(widget.store),
|
||||||
ActionChip(
|
|
||||||
avatar: const Icon(Icons.handshake, size: 16),
|
|
||||||
label: Text(
|
|
||||||
"${widget.store.associatedProviders.length} Providers",
|
|
||||||
),
|
),
|
||||||
onPressed: () => _manageStoreProviders(widget.store),
|
const SizedBox(width: 16),
|
||||||
),
|
ActionChip(
|
||||||
],
|
avatar: const Icon(Icons.handshake, size: 16),
|
||||||
);
|
label: Text(
|
||||||
},
|
"${widget.store.associatedProviders.length} Providers",
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
onPressed: () =>
|
||||||
TextButton.icon(
|
_manageStoreProviders(widget.store),
|
||||||
onPressed: () => _openStoreForm(context, store: widget.store),
|
),
|
||||||
icon: const Icon(Icons.edit, size: 18),
|
],
|
||||||
label: const Text("Modifica"),
|
);
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/core/widgets/flux_text_field.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/bloc/store_cubit.dart';
|
||||||
import 'package:flux/features/master_data/store/models/store_model.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 capController = TextEditingController();
|
||||||
final comuneController = TextEditingController();
|
final comuneController = TextEditingController();
|
||||||
final provinciaController = TextEditingController();
|
final provinciaController = TextEditingController();
|
||||||
|
String?
|
||||||
|
_selectedDefaultProviderId; // Per tenere traccia del provider di default selezionato
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -29,129 +32,241 @@ class _StoreFormState extends State<StoreForm> {
|
|||||||
capController.text = widget.store!.zipCode;
|
capController.text = widget.store!.zipCode;
|
||||||
comuneController.text = widget.store!.city;
|
comuneController.text = widget.store!.city;
|
||||||
provinciaController.text = widget.store!.province;
|
provinciaController.text = widget.store!.province;
|
||||||
|
_selectedDefaultProviderId = widget.store!.defaultProviderId;
|
||||||
}
|
}
|
||||||
|
context.read<ProviderListCubit>().loadProviders(
|
||||||
|
widget.store!.id!,
|
||||||
|
); // Carichiamo i gestori per la dropdown
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return BlocListener<StoreCubit, StoreState>(
|
||||||
decoration: BoxDecoration(
|
listener: (context, state) {
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
if (state.status == StoreStatus.success) {
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
// 1. Diciamo alla schermata di ricaricare la lista generale dei negozi (se serve)
|
||||||
),
|
context.read<StoreCubit>().loadStores();
|
||||||
padding: EdgeInsets.only(
|
|
||||||
top: 24,
|
// 🥷 2. IL TOCCO FINALE: Aggiorniamo la sessione globale se stiamo modificando il negozio attivo!
|
||||||
left: 24,
|
if (state.savedStore != null) {
|
||||||
right: 24,
|
context.read<SessionCubit>().updateCurrentStoreLocally(
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
state.savedStore!,
|
||||||
),
|
);
|
||||||
child: SingleChildScrollView(
|
}
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
// 3. Chiudiamo il form
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
children: [
|
const SnackBar(
|
||||||
Text(
|
content: Text(
|
||||||
widget.store == null ? "Nuovo Punto Vendita" : "Modifica Negozio",
|
'Negozio aggiornato con successo!',
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
// --- DATI PRINCIPALI ---
|
if (state.status == StoreStatus.failure) {
|
||||||
FluxTextField(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
controller: nomeController,
|
SnackBar(
|
||||||
label: "Nome Negozio (es. Flux Milano)",
|
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
|
||||||
icon: Icons.storefront_rounded,
|
backgroundColor: Colors.red,
|
||||||
keyboardType: TextInputType.name,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
);
|
||||||
FluxTextField(
|
}
|
||||||
controller: indirizzoController,
|
},
|
||||||
label: "Indirizzo",
|
child: Container(
|
||||||
icon: Icons.map_outlined,
|
decoration: BoxDecoration(
|
||||||
keyboardType: TextInputType.streetAddress,
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
|
padding: EdgeInsets.only(
|
||||||
// --- CAP, COMUNE, PROVINCIA (In riga) ---
|
top: 24,
|
||||||
Row(
|
left: 24,
|
||||||
children: [
|
right: 24,
|
||||||
Expanded(
|
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||||
flex: 2,
|
),
|
||||||
child: FluxTextField(
|
child: SingleChildScrollView(
|
||||||
controller: capController,
|
child: Column(
|
||||||
label: "CAP",
|
mainAxisSize: MainAxisSize.min,
|
||||||
icon: Icons.post_add_rounded,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
keyboardType: TextInputType.number,
|
children: [
|
||||||
maxLength: 5,
|
Text(
|
||||||
),
|
widget.store == null
|
||||||
),
|
? "Nuovo Punto Vendita"
|
||||||
const SizedBox(width: 8),
|
: "Modifica Negozio",
|
||||||
Expanded(
|
style: const TextStyle(
|
||||||
flex: 4,
|
fontSize: 20,
|
||||||
child: FluxTextField(
|
fontWeight: FontWeight.bold,
|
||||||
controller: comuneController,
|
|
||||||
label: "Comune",
|
|
||||||
icon: Icons.location_city_rounded,
|
|
||||||
keyboardType: TextInputType.name,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: FluxTextField(
|
|
||||||
controller: provinciaController,
|
|
||||||
label: "Prov",
|
|
||||||
icon: Icons.explore_outlined,
|
|
||||||
keyboardType: TextInputType.name,
|
|
||||||
onChanged: (value) => value.toUpperCase(),
|
|
||||||
maxLength: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// --- TASTO SALVA ---
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 50,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (nomeController.text.isEmpty) return;
|
|
||||||
|
|
||||||
final storeData = StoreModel(
|
|
||||||
id: widget
|
|
||||||
.store
|
|
||||||
?.id, // Se nullo, Supabase ne crea uno nuovo
|
|
||||||
name: nomeController.text,
|
|
||||||
address: indirizzoController.text,
|
|
||||||
zipCode: capController.text,
|
|
||||||
city: comuneController.text,
|
|
||||||
province: provinciaController.text,
|
|
||||||
companyId: context
|
|
||||||
.read<SessionCubit>()
|
|
||||||
.state
|
|
||||||
.company!
|
|
||||||
.id!, // Recuperiamo la companyId
|
|
||||||
isActive: widget.store?.isActive ?? true,
|
|
||||||
isPaid: widget.store?.isPaid ?? false,
|
|
||||||
paymentExpiration: widget.store?.paymentExpiration,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Chiamata al Bloc per il salvataggio
|
|
||||||
context.read<StoreCubit>().createStore(storeData);
|
|
||||||
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
widget.store == null ? "CREA NEGOZIO" : "AGGIORNA DATI",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
],
|
|
||||||
|
// --- DATI PRINCIPALI ---
|
||||||
|
FluxTextField(
|
||||||
|
controller: nomeController,
|
||||||
|
label: "Nome Negozio (es. Flux Milano)",
|
||||||
|
icon: Icons.storefront_rounded,
|
||||||
|
keyboardType: TextInputType.name,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FluxTextField(
|
||||||
|
controller: indirizzoController,
|
||||||
|
label: "Indirizzo",
|
||||||
|
icon: Icons.map_outlined,
|
||||||
|
keyboardType: TextInputType.streetAddress,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// --- CAP, COMUNE, PROVINCIA (In riga) ---
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: FluxTextField(
|
||||||
|
controller: capController,
|
||||||
|
label: "CAP",
|
||||||
|
icon: Icons.post_add_rounded,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
maxLength: 5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: FluxTextField(
|
||||||
|
controller: comuneController,
|
||||||
|
label: "Comune",
|
||||||
|
icon: Icons.location_city_rounded,
|
||||||
|
keyboardType: TextInputType.name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: FluxTextField(
|
||||||
|
controller: provinciaController,
|
||||||
|
label: "Prov",
|
||||||
|
icon: Icons.explore_outlined,
|
||||||
|
keyboardType: TextInputType.name,
|
||||||
|
onChanged: (value) => value.toUpperCase(),
|
||||||
|
maxLength: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// --- GESTORI ---
|
||||||
|
_defaultProviderDropdown(),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// --- TASTO SALVA ---
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (nomeController.text.isEmpty) return;
|
||||||
|
|
||||||
|
final storeData = StoreModel(
|
||||||
|
id: widget
|
||||||
|
.store
|
||||||
|
?.id, // Se nullo, Supabase ne crea uno nuovo
|
||||||
|
name: nomeController.text,
|
||||||
|
address: indirizzoController.text,
|
||||||
|
zipCode: capController.text,
|
||||||
|
city: comuneController.text,
|
||||||
|
province: provinciaController.text,
|
||||||
|
companyId: context
|
||||||
|
.read<SessionCubit>()
|
||||||
|
.state
|
||||||
|
.company!
|
||||||
|
.id!, // Recuperiamo la companyId
|
||||||
|
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>().saveStore(storeData);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
widget.store == null ? "CREA NEGOZIO" : "AGGIORNA DATI",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
94
lib/features/notes/blocs/notes_cubit.dart
Normal file
94
lib/features/notes/blocs/notes_cubit.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
part of 'notes_bloc.dart';
|
part of 'notes_cubit.dart';
|
||||||
|
|
||||||
enum NotesStatus { initial, loading, success, failure }
|
enum NotesStatus { initial, loading, success, failure }
|
||||||
|
|
||||||
class NotesState {
|
class NotesState extends Equatable {
|
||||||
final NotesStatus status;
|
final NotesStatus status;
|
||||||
final List<NoteModel> notes;
|
final List<NoteModel> notes;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
@@ -26,14 +26,5 @@ class NotesState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
List<Object?> get props => [status, notes, errorMessage];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/core/enums_and_consts/consts.dart';
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:flux/features/notes/models/note_model.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);
|
await _supabase.from('note_collaborators').delete().eq('note_id', noteId);
|
||||||
|
|
||||||
// 3. RE-INSERIMENTO DELLA LISTA AGGIORNATA
|
// 3. RE-INSERIMENTO DELLA LISTA AGGIORNATA
|
||||||
// Se ci sono collaboratori da inserire, li prepariamo in blocco (Bulk Insert)
|
|
||||||
if (note.collaboratorIds.isNotEmpty) {
|
if (note.collaboratorIds.isNotEmpty) {
|
||||||
final collaboratorsToInsert = note.collaboratorIds
|
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();
|
.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)
|
// Restituiamo l'id alla UI (fondamentale per la nostra logica Ninja di creazione)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/core/utils/debouncer.dart';
|
import 'package:flux/core/utils/debouncer.dart';
|
||||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.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';
|
import 'package:flux/features/notes/models/note_model.dart';
|
||||||
|
|
||||||
class NoteFormScreen extends StatefulWidget {
|
class NoteFormScreen extends StatefulWidget {
|
||||||
@@ -20,7 +20,7 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
|
|||||||
NoteModel get _note => widget.note;
|
NoteModel get _note => widget.note;
|
||||||
late TextEditingController _titleController;
|
late TextEditingController _titleController;
|
||||||
late TextEditingController _contentController;
|
late TextEditingController _contentController;
|
||||||
late final NotesBloc _notesBloc;
|
late final NotesCubit _notesCubit;
|
||||||
late String _selectedColor;
|
late String _selectedColor;
|
||||||
late bool _isPinned;
|
late bool _isPinned;
|
||||||
late bool _isSharedAll;
|
late bool _isSharedAll;
|
||||||
@@ -43,7 +43,7 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_titleController = TextEditingController(text: widget.note.title ?? '');
|
_titleController = TextEditingController(text: widget.note.title ?? '');
|
||||||
_contentController = TextEditingController(text: widget.note.content ?? '');
|
_contentController = TextEditingController(text: widget.note.content ?? '');
|
||||||
_notesBloc = context.read<NotesBloc>();
|
_notesCubit = context.read<NotesCubit>();
|
||||||
_selectedColor = widget.note.color;
|
_selectedColor = widget.note.color;
|
||||||
_isPinned = widget.note.isPinned;
|
_isPinned = widget.note.isPinned;
|
||||||
_isSharedAll = widget.note.isSharedAll;
|
_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
|
// 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"
|
/// 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.
|
// Assumiamo che se non ha scritto testo ed è appena stata creata, sia vuota.
|
||||||
if (titleEmpty && contentEmpty) {
|
if (titleEmpty && contentEmpty) {
|
||||||
// Notifichiamo anche il Bloc dell'avvenuta cancellazione così pulisce lo stato locale
|
// Notifichiamo anche il Bloc dell'avvenuta cancellazione così pulisce lo stato locale
|
||||||
_notesBloc.add(NoteDeletedRequested(noteId));
|
_notesCubit.deleteNote(noteId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _deleteNote() {
|
void _deleteNote() {
|
||||||
_notesBloc.add(NoteDeletedRequested(widget.note.id!));
|
_notesCubit.deleteNote(widget.note.id!);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,73 +134,76 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
|
|||||||
builder: (context) {
|
builder: (context) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setModalState) {
|
builder: (context, setModalState) {
|
||||||
return Column(
|
return Material(
|
||||||
children: [
|
color: Colors.transparent,
|
||||||
Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(16.0),
|
children: [
|
||||||
child: Text(
|
Padding(
|
||||||
'Seleziona Collaboratori',
|
padding: const EdgeInsets.all(16.0),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
child: Text(
|
||||||
|
'Seleziona Collaboratori',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
Expanded(
|
child: ListView.builder(
|
||||||
child: ListView.builder(
|
itemCount: allStaff.length,
|
||||||
itemCount: allStaff.length,
|
itemBuilder: (context, index) {
|
||||||
itemBuilder: (context, index) {
|
final staff = allStaff[index];
|
||||||
final staff = allStaff[index];
|
|
||||||
|
|
||||||
// Capiamo se questo membro dello staff è il creatore
|
// Capiamo se questo membro dello staff è il creatore
|
||||||
final isCreator = staff.id == creatorId;
|
final isCreator = staff.id == creatorId;
|
||||||
// È spuntato se è il creatore OPPURE se è nella lista dei collaboratori
|
// È spuntato se è il creatore OPPURE se è nella lista dei collaboratori
|
||||||
final isSelected =
|
final isSelected =
|
||||||
isCreator || _selectedStaffIds.contains(staff.id);
|
isCreator || _selectedStaffIds.contains(staff.id);
|
||||||
|
|
||||||
return CheckboxListTile(
|
return CheckboxListTile(
|
||||||
title: RichText(
|
title: RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
children: [
|
children: [
|
||||||
TextSpan(text: staff.name),
|
TextSpan(text: staff.name),
|
||||||
if (isCreator)
|
if (isCreator)
|
||||||
const TextSpan(
|
const TextSpan(
|
||||||
text: ' (Proprietario)',
|
text: ' (Proprietario)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
value: isSelected,
|
||||||
value: isSelected,
|
activeColor: Theme.of(context).colorScheme.primary,
|
||||||
activeColor: Theme.of(context).colorScheme.primary,
|
// IL TRUCCO NINJA: se è il creatore, passiamo null per disabilitare la spunta!
|
||||||
// IL TRUCCO NINJA: se è il creatore, passiamo null per disabilitare la spunta!
|
onChanged: isCreator
|
||||||
onChanged: isCreator
|
? null
|
||||||
? null
|
: (bool? value) {
|
||||||
: (bool? value) {
|
setModalState(() {
|
||||||
setModalState(() {
|
if (value == true) {
|
||||||
if (value == true) {
|
_selectedStaffIds.add(staff.id!);
|
||||||
_selectedStaffIds.add(staff.id!);
|
} else {
|
||||||
} else {
|
_selectedStaffIds.remove(staff.id!);
|
||||||
_selectedStaffIds.remove(staff.id!);
|
}
|
||||||
}
|
});
|
||||||
});
|
setState(() {});
|
||||||
setState(() {});
|
_triggerAutoSave();
|
||||||
_triggerAutoSave();
|
},
|
||||||
},
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.all(16.0),
|
||||||
padding: const EdgeInsets.all(16.0),
|
child: FilledButton(
|
||||||
child: FilledButton(
|
onPressed: () => Navigator.pop(context),
|
||||||
onPressed: () => Navigator.pop(context),
|
child: const Text('Fatto'),
|
||||||
child: const Text('Fatto'),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -248,89 +251,121 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// --- HEADER DEL POST-IT (Tavolozza + Azioni) ---
|
// --- HEADER DEL POST-IT (Tavolozza + Azioni) ---
|
||||||
Row(
|
LayoutBuilder(
|
||||||
children: [
|
builder: (context, constraints) {
|
||||||
// Tavolozza Colori
|
// 1. Capiamo quanto spazio reale ha la finestra in questo momento
|
||||||
Expanded(
|
final isNarrow = constraints.maxWidth < 500;
|
||||||
child: SizedBox(
|
|
||||||
height: 40,
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
itemCount: _noteColors.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final colorHex = _noteColors[index];
|
|
||||||
final isSelected = _selectedColor == colorHex;
|
|
||||||
final c = Color(
|
|
||||||
int.parse(
|
|
||||||
'FF${colorHex.replaceAll('#', '')}',
|
|
||||||
radix: 16,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return GestureDetector(
|
// 2. Adattiamo la dimensione dei cerchi
|
||||||
onTap: () {
|
final double circleSize = isNarrow ? 32.0 : 40.0;
|
||||||
setState(() => _selectedColor = colorHex);
|
|
||||||
_triggerAutoSave();
|
// -- PREPARIAMO IL BLOCCO COLORI --
|
||||||
},
|
final colorPalette = SizedBox(
|
||||||
child: Container(
|
height: circleSize,
|
||||||
margin: const EdgeInsets.only(right: 12),
|
child: ListView.builder(
|
||||||
width: 40,
|
scrollDirection: Axis.horizontal,
|
||||||
decoration: BoxDecoration(
|
itemCount: _noteColors.length,
|
||||||
color: c,
|
itemBuilder: (context, index) {
|
||||||
shape: BoxShape.circle,
|
final colorHex = _noteColors[index];
|
||||||
border: Border.all(
|
final isSelected = _selectedColor == colorHex;
|
||||||
color: isSelected
|
final c = Color(
|
||||||
? Colors.black54
|
int.parse(
|
||||||
: Colors.black12,
|
'FF${colorHex.replaceAll('#', '')}',
|
||||||
width: isSelected ? 3 : 1,
|
radix: 16,
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() => _selectedColor = colorHex);
|
||||||
|
_triggerAutoSave();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(right: 12),
|
||||||
|
width: circleSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: c,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.black54
|
||||||
|
: Colors.black12,
|
||||||
|
width: isSelected ? 3 : 1,
|
||||||
),
|
),
|
||||||
child: isSelected
|
|
||||||
? const Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: Colors.black54,
|
|
||||||
size: 20,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
);
|
child: isSelected
|
||||||
|
? Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: Colors.black54,
|
||||||
|
size: isNarrow ? 16 : 20,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- PREPARIAMO IL BLOCCO AZIONI --
|
||||||
|
final actionButtons = Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
tooltip: 'Elimina',
|
||||||
|
onPressed: _deleteNote,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isPinned
|
||||||
|
? Icons.push_pin
|
||||||
|
: Icons.push_pin_outlined,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
tooltip: _isPinned
|
||||||
|
? 'Rimuovi in alto'
|
||||||
|
: 'Fissa in alto',
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _isPinned = !_isPinned);
|
||||||
|
_triggerAutoSave();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
IconButton(
|
||||||
),
|
icon: const Icon(
|
||||||
const SizedBox(width: 16),
|
Icons.ios_share,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
tooltip: 'Esporta',
|
||||||
|
onPressed: _exportNote,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Azioni spostate dentro la nota!
|
// 3. DECIDIAMO IL LAYOUT FINALE IN BASE ALLO SPAZIO REALE
|
||||||
IconButton(
|
if (isNarrow) {
|
||||||
icon: const Icon(
|
return Column(
|
||||||
Icons.delete_outline,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
color: Colors.black87,
|
children: [
|
||||||
),
|
actionButtons,
|
||||||
tooltip: 'Elimina',
|
|
||||||
onPressed: _deleteNote,
|
const SizedBox(height: 8),
|
||||||
),
|
colorPalette,
|
||||||
IconButton(
|
],
|
||||||
icon: Icon(
|
);
|
||||||
_isPinned ? Icons.push_pin : Icons.push_pin_outlined,
|
}
|
||||||
color: Colors.black87,
|
|
||||||
),
|
// Layout "Largo" (Finestra intera)
|
||||||
tooltip: _isPinned
|
return Row(
|
||||||
? 'Rimuovi in alto'
|
children: [
|
||||||
: 'Fissa in alto',
|
Expanded(child: colorPalette),
|
||||||
onPressed: () {
|
const SizedBox(width: 16),
|
||||||
setState(() => _isPinned = !_isPinned);
|
actionButtons,
|
||||||
_triggerAutoSave();
|
],
|
||||||
},
|
);
|
||||||
),
|
},
|
||||||
IconButton(
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.ios_share,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
tooltip: 'Esporta',
|
|
||||||
onPressed: _exportNote,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
@@ -371,24 +406,27 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// --- CONDIVISIONE ---
|
// --- CONDIVISIONE ---
|
||||||
SwitchListTile(
|
Material(
|
||||||
title: const Text(
|
color: Colors.transparent,
|
||||||
'Condividi con tutti',
|
child: SwitchListTile(
|
||||||
style: TextStyle(
|
title: const Text(
|
||||||
color: Colors.black87,
|
'Condividi con tutti',
|
||||||
fontWeight: FontWeight.w500,
|
style: TextStyle(
|
||||||
|
color: Colors.black87,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
value: _isSharedAll,
|
||||||
|
activeThumbColor: Colors.black87,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_isSharedAll = val;
|
||||||
|
if (val) _selectedStaffIds.clear();
|
||||||
|
});
|
||||||
|
_triggerAutoSave();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
value: _isSharedAll,
|
|
||||||
activeThumbColor: Colors.black87,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
onChanged: (val) {
|
|
||||||
setState(() {
|
|
||||||
_isSharedAll = val;
|
|
||||||
if (val) _selectedStaffIds.clear();
|
|
||||||
});
|
|
||||||
_triggerAutoSave();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
|
||||||
if (!_isSharedAll) ...[
|
if (!_isSharedAll) ...[
|
||||||
|
|||||||
@@ -2,16 +2,54 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||||
import 'package:flux/core/routes/routes.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:flux/features/notes/data/notes_repository.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/features/notes/models/note_model.dart';
|
import 'package:flux/features/notes/models/note_model.dart';
|
||||||
|
|
||||||
class NotesListScreen extends StatelessWidget {
|
class NotesListScreen extends StatefulWidget {
|
||||||
const NotesListScreen({super.key});
|
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
|
/// Logica Ninja: Crea la nota vuota, prende l'ID, e apre il form
|
||||||
Future<void> _createNewNoteAndNavigate(BuildContext context) async {
|
Future<void> _createNewNoteAndNavigate(BuildContext context) async {
|
||||||
final sessionState = context.read<SessionCubit>().state;
|
final sessionState = context.read<SessionCubit>().state;
|
||||||
@@ -73,7 +111,7 @@ class NotesListScreen extends StatelessWidget {
|
|||||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
body: BlocBuilder<NotesBloc, NotesState>(
|
body: BlocBuilder<NotesCubit, NotesState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.status == NotesStatus.loading && state.notes.isEmpty) {
|
if (state.status == NotesStatus.loading && state.notes.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:equatable/equatable.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/features/customers/models/customer_model.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/master_data/staff/models/staff_member_model.dart';
|
||||||
import 'package:flux/features/operations/data/operations_repository.dart';
|
import 'package:flux/features/operations/data/operations_repository.dart';
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
import 'package:flux/features/operations/models/operation_model.dart';
|
||||||
@@ -96,17 +98,23 @@ class OperationFormCubit extends Cubit<OperationFormState> {
|
|||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: OperationFormStatus.ready, // Torna ready per il nuovo form
|
status: OperationFormStatus.ready,
|
||||||
operation: OperationModel(
|
operation: OperationModel(
|
||||||
companyId: current.companyId,
|
companyId: current.companyId,
|
||||||
storeId: current.storeId,
|
storeId: current.storeId,
|
||||||
storeDisplayName: current.storeDisplayName,
|
storeDisplayName: current.storeDisplayName,
|
||||||
batchUuid: current.batchUuid, // MANTIENE IL COLLEGAMENTO
|
// 🥷 REINSERIAMO LO STAFF (Il "colpevole" era qui)
|
||||||
customerId: current.customerId, // MANTIENE IL CLIENTE
|
staffId: current.staffId,
|
||||||
|
staffDisplayName: current.staffDisplayName,
|
||||||
|
|
||||||
|
batchUuid: current.batchUuid,
|
||||||
|
customerId: current.customerId,
|
||||||
customer: current.customer,
|
customer: current.customer,
|
||||||
reference: current.reference,
|
reference: current.reference,
|
||||||
status: OperationStatus.draft,
|
status: OperationStatus.draft,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
|
// Mantieni isBusiness se vuoi che rimanga coerente col cliente
|
||||||
|
isBusiness: current.isBusiness,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -209,9 +217,7 @@ class OperationFormCubit extends Cubit<OperationFormState> {
|
|||||||
String? reference,
|
String? reference,
|
||||||
String? note,
|
String? note,
|
||||||
String? type,
|
String? type,
|
||||||
String? providerId,
|
String? subType,
|
||||||
String? providerDisplayName,
|
|
||||||
String? subtype,
|
|
||||||
String? description,
|
String? description,
|
||||||
DateTime? expirationDate,
|
DateTime? expirationDate,
|
||||||
int? quantity,
|
int? quantity,
|
||||||
@@ -224,7 +230,7 @@ class OperationFormCubit extends Cubit<OperationFormState> {
|
|||||||
|
|
||||||
bool clearProvider = false,
|
bool clearProvider = false,
|
||||||
bool clearType = false,
|
bool clearType = false,
|
||||||
bool clearSubtype = false,
|
bool clearSubType = false,
|
||||||
bool clearDescription = false,
|
bool clearDescription = false,
|
||||||
bool clearExpiration = false,
|
bool clearExpiration = false,
|
||||||
bool clearQuantity = false,
|
bool clearQuantity = false,
|
||||||
@@ -240,16 +246,12 @@ class OperationFormCubit extends Cubit<OperationFormState> {
|
|||||||
final updated = current.copyWith(
|
final updated = current.copyWith(
|
||||||
reference: reference ?? current.reference,
|
reference: reference ?? current.reference,
|
||||||
note: note ?? current.note,
|
note: note ?? current.note,
|
||||||
providerId: clearProvider ? null : (providerId ?? current.providerId),
|
|
||||||
providerDisplayName: clearProvider
|
|
||||||
? null
|
|
||||||
: (providerDisplayName ?? current.providerDisplayName),
|
|
||||||
quantity: newQuantity ?? current.quantity,
|
quantity: newQuantity ?? current.quantity,
|
||||||
type: clearType ? null : (type ?? current.type),
|
type: clearType ? null : (type ?? current.type),
|
||||||
description: clearDescription
|
description: clearDescription
|
||||||
? null
|
? null
|
||||||
: (description ?? current.description),
|
: (description ?? current.description),
|
||||||
subtype: clearSubtype ? null : (subtype ?? current.subtype),
|
subType: clearSubType ? null : (subType ?? current.subType),
|
||||||
expirationDate: clearExpiration
|
expirationDate: clearExpiration
|
||||||
? null
|
? null
|
||||||
: (expirationDate ?? current.expirationDate),
|
: (expirationDate ?? current.expirationDate),
|
||||||
@@ -266,6 +268,17 @@ class OperationFormCubit extends Cubit<OperationFormState> {
|
|||||||
emit(state.copyWith(operation: updated));
|
emit(state.copyWith(operation: updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updateProvider(ProviderModel? newProvider) {
|
||||||
|
final current = state.operation;
|
||||||
|
|
||||||
|
final updatedOperation = current.copyWith(
|
||||||
|
// Se newProvider è null, passiamo una funzione che ritorna null per sbiancare i campi!
|
||||||
|
provider: () => newProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(state.copyWith(operation: updatedOperation));
|
||||||
|
}
|
||||||
|
|
||||||
void updateCustomer(CustomerModel customer) {
|
void updateCustomer(CustomerModel customer) {
|
||||||
final bool isBusiness = customer.isBusiness;
|
final bool isBusiness = customer.isBusiness;
|
||||||
final updatedOperation = state.operation.copyWith(
|
final updatedOperation = state.operation.copyWith(
|
||||||
@@ -278,27 +291,101 @@ class OperationFormCubit extends Cubit<OperationFormState> {
|
|||||||
|
|
||||||
// --- UTILS ---
|
// --- 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)
|
||||||
|
|
||||||
|
// 2. 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)) {
|
||||||
|
updateProvider(defaultProvider);
|
||||||
|
} else {
|
||||||
|
// Se cambi tipo (es. da Mobile a Luce) e il default non lo supporta, sbianchiamo
|
||||||
|
updateProvider(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setTypeWithSmartDefaults({
|
||||||
|
required String newType,
|
||||||
|
required List<ProviderModel> allProviders,
|
||||||
|
String? defaultProviderId,
|
||||||
|
}) {
|
||||||
|
final currentOp = state.operation;
|
||||||
|
|
||||||
|
// -----------------------------------------
|
||||||
|
// 1. SMART DATES: Calcolo Scadenze Default (Invariato)
|
||||||
|
// -----------------------------------------
|
||||||
DateTime? defaultDate;
|
DateTime? defaultDate;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
if (type == 'Energy') {
|
if (newType == 'Energy') {
|
||||||
defaultDate = DateTime(now.year, now.month + 24, now.day);
|
defaultDate = DateTime(now.year, now.month + 24, now.day);
|
||||||
}
|
}
|
||||||
if (type == 'Fin') {
|
if (newType == 'Fin') {
|
||||||
defaultDate = DateTime(now.year, now.month + 30, now.day);
|
defaultDate = DateTime(now.year, now.month + 30, now.day);
|
||||||
}
|
}
|
||||||
if (type == 'Entertainment') {
|
if (newType == 'Entertainment') {
|
||||||
defaultDate = DateTime(now.year, now.month + 12, now.day);
|
defaultDate = DateTime(now.year, now.month + 12, now.day);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFields(
|
// -----------------------------------------
|
||||||
type: type,
|
// 2. SMART PROVIDER: Filtro e Auto-Selezione ad Oggetti
|
||||||
expirationDate: defaultDate,
|
// -----------------------------------------
|
||||||
clearProvider: true,
|
// Pescatore direttamente l'oggetto dal modello corrente
|
||||||
clearSubtype: true,
|
ProviderModel? targetProvider = currentOp.provider;
|
||||||
clearModel: true,
|
|
||||||
clearQuantity: true,
|
// A) Il provider attuale è ancora compatibile col nuovo tipo scelto?
|
||||||
|
if (targetProvider != null && !targetProvider.supportsOperation(newType)) {
|
||||||
|
// Non è più compatibile (es. da TIM fisso passo a Energy). Lo sbianchiamo!
|
||||||
|
targetProvider = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// B) Se non c'è un provider selezionato, proviamo ad auto-inserire quello di default del negozio
|
||||||
|
if (targetProvider == null && 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)) {
|
||||||
|
targetProvider = defaultProvider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------
|
||||||
|
// 3. EMISSIONE DELLO STATO PULITO
|
||||||
|
// -----------------------------------------
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
operation: currentOp.copyWith(
|
||||||
|
type: newType,
|
||||||
|
subType: '', // Resettiamo il sottotipo per evitare incongruenze
|
||||||
|
expirationDate:
|
||||||
|
defaultDate, // Impostiamo la scadenza di default se calcolata
|
||||||
|
// 🥷 APPLICHIAMO IL TRUCCO NINJA DELLE FUNZIONI
|
||||||
|
// Se targetProvider è null, le funzioni ritorneranno null sbiancando il DB!
|
||||||
|
provider: () => targetProvider,
|
||||||
|
|
||||||
|
// Nota: Per azzerare davvero questi due, ricordati in futuro di applicare
|
||||||
|
// il trucco delle funzioni anche a modelId e modelDisplayName nel modello!
|
||||||
|
modelId: null,
|
||||||
|
modelDisplayName: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,72 +12,103 @@ class OperationListCubit extends Cubit<OperationListState> {
|
|||||||
final OperationsRepository _repository = GetIt.I<OperationsRepository>();
|
final OperationsRepository _repository = GetIt.I<OperationsRepository>();
|
||||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||||
|
|
||||||
OperationListCubit() : super(const OperationListState()) {
|
OperationListCubit() : super(const OperationListState());
|
||||||
loadOperations(refresh: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadOperations({bool refresh = false}) async {
|
// 🥷 MOTORE 1: DESKTOP (Sostituisce la lista)
|
||||||
|
Future<void> loadSpecificPageDesktop(int page) async {
|
||||||
if (state.status == OperationListStatus.loading) return;
|
if (state.status == OperationListStatus.loading) return;
|
||||||
if (!refresh && state.hasReachedMax) return;
|
emit(state.copyWith(status: OperationListStatus.loading));
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: OperationListStatus.loading,
|
|
||||||
errorMessage: null,
|
|
||||||
operations: refresh ? [] : state.operations,
|
|
||||||
hasReachedMax: refresh ? false : state.hasReachedMax,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final currentOffset = refresh ? 0 : state.operations.length;
|
|
||||||
final companyId = _sessionCubit.state.company?.id;
|
final companyId = _sessionCubit.state.company?.id;
|
||||||
|
final paginatedData = await _repository.fetchPaginatedOperations(
|
||||||
if (companyId == null) {
|
companyId: companyId!,
|
||||||
throw Exception("Company ID non trovato nella sessione");
|
page: page,
|
||||||
}
|
itemsPerPage: state.itemsPerPage,
|
||||||
|
|
||||||
final newOperations = await _repository.fetchOperations(
|
|
||||||
companyId: companyId,
|
|
||||||
offset: currentOffset,
|
|
||||||
limit: 50,
|
|
||||||
searchTerm: state.query,
|
|
||||||
dateRange: state.dateRange,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final bool reachedMax = newOperations.length < 50;
|
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: OperationListStatus.success,
|
status: OperationListStatus.success,
|
||||||
operations: refresh
|
operations: paginatedData.operations, // 🎯 SOSTITUISCE I DATI
|
||||||
? newOperations
|
totalItems: paginatedData.totalCount,
|
||||||
: [...state.operations, ...newOperations],
|
currentPage: page,
|
||||||
hasReachedMax: reachedMax,
|
hasReachedMax: paginatedData.operations.length < state.itemsPerPage,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
status: OperationListStatus.failure,
|
status: OperationListStatus.failure,
|
||||||
errorMessage: "Errore nel caricamento operazioni: $e",
|
errorMessage: e.toString(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateFilters({String? query, DateTimeRange? range}) {
|
// 🥷 MOTORE 2: MOBILE (Accoda alla lista)
|
||||||
|
Future<void> loadNextPageMobile({bool refresh = false}) async {
|
||||||
|
if (state.status == OperationListStatus.loading) return;
|
||||||
|
if (state.hasReachedMax && !refresh) return;
|
||||||
|
|
||||||
|
// Se stiamo pullando verso il basso (refresh), ripartiamo da pagina 1
|
||||||
|
final targetPage = refresh ? 1 : state.currentPage + 1;
|
||||||
|
|
||||||
|
// Mostriamo il loading solo se è un refresh totale, altrimenti manteniamo lo stato success
|
||||||
|
// per non far sparire la UI mentre carica in fondo
|
||||||
|
if (refresh) emit(state.copyWith(status: OperationListStatus.loading));
|
||||||
|
|
||||||
|
try {
|
||||||
|
final companyId = _sessionCubit.state.company?.id;
|
||||||
|
final paginatedData = await _repository.fetchPaginatedOperations(
|
||||||
|
companyId: companyId!,
|
||||||
|
page: targetPage,
|
||||||
|
itemsPerPage: state.itemsPerPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationListStatus.success,
|
||||||
|
// 🎯 ACCODA I DATI SE NON È REFRESH, ALTRIMENTI SOSTITUISCE
|
||||||
|
operations:
|
||||||
|
refresh ? paginatedData.operations : List.of(state.operations)
|
||||||
|
..addAll(paginatedData.operations),
|
||||||
|
totalItems: paginatedData.totalCount,
|
||||||
|
currentPage: targetPage,
|
||||||
|
hasReachedMax: paginatedData.operations.length < state.itemsPerPage,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: OperationListStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFilters({String? text, DateTimeRange? range}) {
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
query: query ?? state.query,
|
// 🥷 FORZIAMO IL TIPO: Diciamo a Dart che il risultato del ternario è proprio una funzione
|
||||||
dateRange: range ?? state.dateRange,
|
searchTerm: text != null ? () => text : null,
|
||||||
|
dateRange: range != null ? () => range : null,
|
||||||
|
|
||||||
|
currentPage: 1, // Reset obbligatorio alla prima pagina
|
||||||
|
hasReachedMax: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
loadOperations(refresh: true);
|
|
||||||
|
// Ricarichiamo la pagina 1 con i nuovi filtri applicati
|
||||||
|
loadSpecificPageDesktop(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearFilters() {
|
void clearFilters() {
|
||||||
emit(const OperationListState()); // Resetta tutto allo stato iniziale
|
// Invece di un const vuoto che potrebbe bruciarti l'impostazione itemsPerPage,
|
||||||
loadOperations(refresh: true);
|
// creiamo uno stato pulito ma manteniamo la preferenza di paginazione.
|
||||||
|
emit(OperationListState(itemsPerPage: state.itemsPerPage));
|
||||||
|
|
||||||
|
loadSpecificPageDesktop(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,35 +5,57 @@ enum OperationListStatus { initial, loading, success, failure }
|
|||||||
class OperationListState extends Equatable {
|
class OperationListState extends Equatable {
|
||||||
final OperationListStatus status;
|
final OperationListStatus status;
|
||||||
final List<OperationModel> operations;
|
final List<OperationModel> operations;
|
||||||
final bool hasReachedMax;
|
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String query;
|
|
||||||
|
// Paginazione Ibrida
|
||||||
|
final int currentPage;
|
||||||
|
final int itemsPerPage;
|
||||||
|
final int totalItems;
|
||||||
|
final bool hasReachedMax;
|
||||||
|
|
||||||
|
// 🥷 I FILTRI MANCANTI (Riparati!)
|
||||||
|
final String? searchTerm;
|
||||||
final DateTimeRange? dateRange;
|
final DateTimeRange? dateRange;
|
||||||
|
|
||||||
const OperationListState({
|
const OperationListState({
|
||||||
this.status = OperationListStatus.initial,
|
this.status = OperationListStatus.initial,
|
||||||
this.operations = const [],
|
this.operations = const [],
|
||||||
this.hasReachedMax = false,
|
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.query = '',
|
this.currentPage = 1,
|
||||||
|
this.itemsPerPage = 25,
|
||||||
|
this.totalItems = 0,
|
||||||
|
this.hasReachedMax = false,
|
||||||
|
this.searchTerm,
|
||||||
this.dateRange,
|
this.dateRange,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
int get totalPages => (totalItems / itemsPerPage).ceil();
|
||||||
|
|
||||||
|
// 🥷 COPYWITH AVANZATO: Gestisce lo sbiancamento dei filtri alla perfezione
|
||||||
OperationListState copyWith({
|
OperationListState copyWith({
|
||||||
OperationListStatus? status,
|
OperationListStatus? status,
|
||||||
List<OperationModel>? operations,
|
List<OperationModel>? operations,
|
||||||
bool? hasReachedMax,
|
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
String? query,
|
int? currentPage,
|
||||||
DateTimeRange? dateRange,
|
int? itemsPerPage,
|
||||||
|
int? totalItems,
|
||||||
|
bool? hasReachedMax,
|
||||||
|
String? Function()? searchTerm, // Callback per gestire il null esplicito
|
||||||
|
DateTimeRange? Function()?
|
||||||
|
dateRange, // Callback per gestire il null esplicito
|
||||||
}) {
|
}) {
|
||||||
return OperationListState(
|
return OperationListState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
operations: operations ?? this.operations,
|
operations: operations ?? this.operations,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
currentPage: currentPage ?? this.currentPage,
|
||||||
|
itemsPerPage: itemsPerPage ?? this.itemsPerPage,
|
||||||
|
totalItems: totalItems ?? this.totalItems,
|
||||||
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
||||||
errorMessage: errorMessage,
|
|
||||||
query: query ?? this.query,
|
// Se passi la funzione la eseguiamo, altrimenti teniamo il valore corrente
|
||||||
dateRange: dateRange ?? this.dateRange,
|
searchTerm: searchTerm != null ? searchTerm() : this.searchTerm,
|
||||||
|
dateRange: dateRange != null ? dateRange() : this.dateRange,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +63,12 @@ class OperationListState extends Equatable {
|
|||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
status,
|
status,
|
||||||
operations,
|
operations,
|
||||||
hasReachedMax,
|
|
||||||
errorMessage,
|
errorMessage,
|
||||||
query,
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
totalItems,
|
||||||
|
hasReachedMax,
|
||||||
|
searchTerm,
|
||||||
dateRange,
|
dateRange,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,22 +35,31 @@ class OperationsRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RECUPERO PAGINATO CON FILTRI E JOIN ---
|
// 🥷 2. RECUPERO PAGINATO ASSOLUTO CON CONTEGGIO TOTALI
|
||||||
Future<List<OperationModel>> fetchOperations({
|
Future<PaginatedOperations> fetchPaginatedOperations({
|
||||||
required String companyId,
|
required String companyId,
|
||||||
required int offset,
|
String? storeId,
|
||||||
int limit = 50,
|
String? staffId,
|
||||||
|
String? providerId,
|
||||||
|
required int page, // Usiamo 'page' (1, 2, 3...) invece di 'offset'
|
||||||
|
int itemsPerPage = 25, // Default a 25 elementi per pagina
|
||||||
String? searchTerm,
|
String? searchTerm,
|
||||||
DateTimeRange? dateRange,
|
DateTimeRange? dateRange,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
|
// Calcoliamo il range di partenza e fine per Supabase
|
||||||
|
// Es. Pagina 1, 25 items -> range(0, 24)
|
||||||
|
// Es. Pagina 2, 25 items -> range(25, 49)
|
||||||
|
final from = (page - 1) * itemsPerPage;
|
||||||
|
final to = from + itemsPerPage - 1;
|
||||||
|
|
||||||
var query = _supabase
|
var query = _supabase
|
||||||
.from(Tables.operations)
|
.from(Tables.operations)
|
||||||
.select('''
|
.select('''
|
||||||
*,
|
*,
|
||||||
${Tables.customers}(*),
|
${Tables.customers}(*),
|
||||||
${Tables.stores}(name),
|
${Tables.stores}(name),
|
||||||
${Tables.providers}(name),
|
${Tables.providers}(*),
|
||||||
${Tables.models}(name_with_brand),
|
${Tables.models}(name_with_brand),
|
||||||
${Tables.staffMembers}(name),
|
${Tables.staffMembers}(name),
|
||||||
${Tables.attachments}(*)
|
${Tables.attachments}(*)
|
||||||
@@ -64,8 +73,19 @@ class OperationsRepository {
|
|||||||
.lte('created_at', dateRange.end.toIso8601String());
|
.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) {
|
if (searchTerm != null && searchTerm.isNotEmpty) {
|
||||||
// Filtra sui campi della tabella principale O su quelli della tabella joinata
|
|
||||||
query = query.or(
|
query = query.or(
|
||||||
'reference.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%',
|
'reference.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%',
|
||||||
);
|
);
|
||||||
@@ -73,17 +93,25 @@ class OperationsRepository {
|
|||||||
|
|
||||||
final response = await query
|
final response = await query
|
||||||
.order('created_at', ascending: false)
|
.order('created_at', ascending: false)
|
||||||
.range(offset, offset + limit - 1);
|
.range(from, to)
|
||||||
|
.count(CountOption.exact);
|
||||||
return (response as List)
|
// 3. Estrazione dei dati
|
||||||
|
final List<OperationModel> operations = (response.data as List)
|
||||||
.map((map) => OperationModel.fromMap(map))
|
.map((map) => OperationModel.fromMap(map))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
final int totalCount = response.count;
|
||||||
|
|
||||||
|
return PaginatedOperations(
|
||||||
|
operations: operations,
|
||||||
|
totalCount: totalCount,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('$e');
|
throw Exception('Errore nel recupero della pagina $page: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<OperationModel>> getLatestStoreOperationsStream({
|
Stream<List<Map<String, dynamic>>> watchStoreOperations({
|
||||||
required String storeId,
|
required String storeId,
|
||||||
required int limit,
|
required int limit,
|
||||||
}) {
|
}) {
|
||||||
@@ -92,11 +120,7 @@ class OperationsRepository {
|
|||||||
.stream(primaryKey: ['id'])
|
.stream(primaryKey: ['id'])
|
||||||
.eq('store_id', storeId)
|
.eq('store_id', storeId)
|
||||||
.order('created_at', ascending: false)
|
.order('created_at', ascending: false)
|
||||||
.limit(limit)
|
.limit(limit);
|
||||||
.map(
|
|
||||||
(listOfMaps) =>
|
|
||||||
listOfMaps.map((map) => OperationModel.fromMap(map)).toList(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
|
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
|
||||||
@@ -310,3 +334,10 @@ class OperationsRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PaginatedOperations {
|
||||||
|
final List<OperationModel> operations;
|
||||||
|
final int totalCount;
|
||||||
|
|
||||||
|
PaginatedOperations({required this.operations, required this.totalCount});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||||
import 'package:flux/core/utils/extensions.dart';
|
import 'package:flux/core/utils/extensions.dart';
|
||||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||||
import 'package:flux/features/customers/models/customer_model.dart';
|
import 'package:flux/features/customers/models/customer_model.dart';
|
||||||
|
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||||
|
|
||||||
enum OperationStatus {
|
enum OperationStatus {
|
||||||
success('success', 'OK'),
|
success('success', 'OK'),
|
||||||
@@ -27,9 +29,7 @@ class OperationModel extends Equatable {
|
|||||||
final String? id;
|
final String? id;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final String type;
|
final String type;
|
||||||
final String? subtype;
|
final String? subType;
|
||||||
final String? providerId;
|
|
||||||
final String? providerDisplayName;
|
|
||||||
final String? modelId;
|
final String? modelId;
|
||||||
final String? modelDisplayName;
|
final String? modelDisplayName;
|
||||||
final String? description;
|
final String? description;
|
||||||
@@ -49,6 +49,7 @@ class OperationModel extends Equatable {
|
|||||||
final CustomerModel? customer;
|
final CustomerModel? customer;
|
||||||
final String reference;
|
final String reference;
|
||||||
final bool isBusiness;
|
final bool isBusiness;
|
||||||
|
final ProviderModel? provider;
|
||||||
|
|
||||||
// ALLEGATI (Aggiunto)
|
// ALLEGATI (Aggiunto)
|
||||||
final List<AttachmentModel> attachments;
|
final List<AttachmentModel> attachments;
|
||||||
@@ -57,9 +58,7 @@ class OperationModel extends Equatable {
|
|||||||
this.id,
|
this.id,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.type = '',
|
this.type = '',
|
||||||
this.subtype,
|
this.subType,
|
||||||
this.providerId,
|
|
||||||
this.providerDisplayName,
|
|
||||||
this.modelId,
|
this.modelId,
|
||||||
this.modelDisplayName,
|
this.modelDisplayName,
|
||||||
this.description,
|
this.description,
|
||||||
@@ -80,15 +79,16 @@ class OperationModel extends Equatable {
|
|||||||
this.reference = '',
|
this.reference = '',
|
||||||
this.attachments = const [],
|
this.attachments = const [],
|
||||||
this.isBusiness = false,
|
this.isBusiness = false,
|
||||||
|
this.provider,
|
||||||
});
|
});
|
||||||
|
|
||||||
OperationModel copyWith({
|
OperationModel copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
String? type,
|
String? type,
|
||||||
String? subtype,
|
String? subType,
|
||||||
String? providerId,
|
// 🥷 TRUCCO APPLICATO ANCHE QUI:
|
||||||
String? providerDisplayName,
|
ProviderModel? Function()? provider,
|
||||||
String? modelId,
|
String? modelId,
|
||||||
String? modelDisplayName,
|
String? modelDisplayName,
|
||||||
String? description,
|
String? description,
|
||||||
@@ -113,9 +113,10 @@ class OperationModel extends Equatable {
|
|||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
subtype: subtype ?? this.subtype,
|
subType: subType ?? this.subType,
|
||||||
providerId: providerId ?? this.providerId,
|
// Se la funzione è passata, la eseguiamo (anche se ritorna null), altrimenti teniamo il vecchio
|
||||||
providerDisplayName: providerDisplayName ?? this.providerDisplayName,
|
provider: provider != null ? provider() : this.provider,
|
||||||
|
|
||||||
modelId: modelId ?? this.modelId,
|
modelId: modelId ?? this.modelId,
|
||||||
modelDisplayName: modelDisplayName ?? this.modelDisplayName,
|
modelDisplayName: modelDisplayName ?? this.modelDisplayName,
|
||||||
description: description ?? this.description,
|
description: description ?? this.description,
|
||||||
@@ -143,9 +144,8 @@ class OperationModel extends Equatable {
|
|||||||
id,
|
id,
|
||||||
createdAt,
|
createdAt,
|
||||||
type,
|
type,
|
||||||
subtype,
|
subType,
|
||||||
providerId,
|
provider,
|
||||||
providerDisplayName,
|
|
||||||
modelId,
|
modelId,
|
||||||
modelDisplayName,
|
modelDisplayName,
|
||||||
description,
|
description,
|
||||||
@@ -179,15 +179,15 @@ class OperationModel extends Equatable {
|
|||||||
? DateTime.parse(map['created_at'])
|
? DateTime.parse(map['created_at'])
|
||||||
: null,
|
: null,
|
||||||
type: map['type'] as String? ?? '',
|
type: map['type'] as String? ?? '',
|
||||||
subtype: map['sub_type'] as String?,
|
subType: map['sub_type'] as String?,
|
||||||
|
|
||||||
// I campi relazionali nullabili restano rigorosamente null!
|
// I campi relazionali nullabili restano rigorosamente null!
|
||||||
providerId: map['provider_id'] as String?,
|
provider: (map[Tables.providers] != null)
|
||||||
// MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti
|
? ProviderModel.fromMap(map[Tables.providers] as Map<String, dynamic>)
|
||||||
providerDisplayName: (map['provider']?['name'] as String?)?.myFormat(),
|
: null,
|
||||||
|
|
||||||
modelId: map['model_id'] as String?,
|
modelId: map['model_id'] as String?,
|
||||||
modelDisplayName: (map['model']?['name_with_brand'] as String?)
|
modelDisplayName: (map[Tables.models]?['name_with_brand'] as String?)
|
||||||
?.myFormat(),
|
?.myFormat(),
|
||||||
|
|
||||||
description: map['description'] as String?,
|
description: map['description'] as String?,
|
||||||
@@ -202,25 +202,26 @@ class OperationModel extends Equatable {
|
|||||||
storeId:
|
storeId:
|
||||||
map['store_id'] as String? ??
|
map['store_id'] as String? ??
|
||||||
'', // Questo è non-nullable nella tua classe
|
'', // Questo è non-nullable nella tua classe
|
||||||
storeDisplayName: (map['store']?['name'] as String?)?.myFormat(),
|
storeDisplayName: (map[Tables.stores]?['name'] as String?)?.myFormat(),
|
||||||
|
|
||||||
quantity: map['quantity'] is int
|
quantity: map['quantity'] is int
|
||||||
? map['quantity']
|
? map['quantity']
|
||||||
: int.tryParse(map['quantity']?.toString() ?? '1') ?? 1,
|
: int.tryParse(map['quantity']?.toString() ?? '1') ?? 1,
|
||||||
|
|
||||||
staffId: map['staff_id'] as String?,
|
staffId: map['staff_id'] as String?,
|
||||||
staffDisplayName: (map['staff_member']?['name'] as String?)?.myFormat(),
|
staffDisplayName: (map[Tables.staffMembers]?['name'] as String?)
|
||||||
|
?.myFormat(),
|
||||||
|
|
||||||
lastCampaignId: map['last_campaign_id'] as String?,
|
lastCampaignId: map['last_campaign_id'] as String?,
|
||||||
status: OperationStatus.fromString(map['status'] ?? 'draft'),
|
status: OperationStatus.fromString(map['status'] ?? 'draft'),
|
||||||
customerId: map['customer_id'] as String?,
|
customerId: map['customer_id'] as String?,
|
||||||
|
|
||||||
customer: map['customer'] != null
|
customer: map[Tables.customers] != null
|
||||||
? CustomerModel.fromMap(map['customer'] as Map<String, dynamic>)
|
? CustomerModel.fromMap(map[Tables.customers] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
|
|
||||||
attachments:
|
attachments:
|
||||||
(map['attachment'] as List?)
|
(map[Tables.attachments] as List?)
|
||||||
?.map((x) => AttachmentModel.fromMap(x))
|
?.map((x) => AttachmentModel.fromMap(x))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
const [],
|
const [],
|
||||||
@@ -234,8 +235,8 @@ class OperationModel extends Equatable {
|
|||||||
return {
|
return {
|
||||||
if (id != null) 'id': id,
|
if (id != null) 'id': id,
|
||||||
'type': type,
|
'type': type,
|
||||||
'sub_type': subtype,
|
'sub_type': subType,
|
||||||
'provider_id': providerId,
|
'provider_id': provider?.id,
|
||||||
'model_id': modelId,
|
'model_id': modelId,
|
||||||
'description': description,
|
'description': description,
|
||||||
if (expirationDate != null)
|
if (expirationDate != null)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||||
import 'package:flux/core/widgets/shared_forms/attachments_section.dart';
|
import 'package:flux/core/widgets/shared_forms/attachments_section.dart';
|
||||||
import 'package:flux/features/attachments/blocs/attachments_bloc.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/blocs/operation_form_cubit.dart';
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
import 'package:flux/features/operations/models/operation_model.dart';
|
||||||
import 'package:flux/core/widgets/shared_forms/customer_section.dart';
|
import 'package:flux/core/widgets/shared_forms/customer_section.dart';
|
||||||
@@ -24,6 +26,9 @@ class OperationFormScreen extends StatefulWidget {
|
|||||||
class _OperationFormScreenState extends State<OperationFormScreen> {
|
class _OperationFormScreenState extends State<OperationFormScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
// 🥷 IL NUOVO FLAG: Ci ricorderà se vogliamo davvero uscire!
|
||||||
|
bool _isClosingIntent = false;
|
||||||
|
|
||||||
final _referenceController = TextEditingController();
|
final _referenceController = TextEditingController();
|
||||||
final _noteController = TextEditingController();
|
final _noteController = TextEditingController();
|
||||||
final _freeTextSubtypeController = TextEditingController();
|
final _freeTextSubtypeController = TextEditingController();
|
||||||
@@ -77,7 +82,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
_noteController.text = model.note;
|
_noteController.text = model.note;
|
||||||
}
|
}
|
||||||
if (_freeTextSubtypeController.text.isEmpty) {
|
if (_freeTextSubtypeController.text.isEmpty) {
|
||||||
_freeTextSubtypeController.text = model.subtype ?? '';
|
_freeTextSubtypeController.text = model.subType ?? '';
|
||||||
}
|
}
|
||||||
if (_freeTextDescriptionController.text.isEmpty) {
|
if (_freeTextDescriptionController.text.isEmpty) {
|
||||||
_freeTextDescriptionController.text = model.description ?? '';
|
_freeTextDescriptionController.text = model.description ?? '';
|
||||||
@@ -89,7 +94,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
context.read<OperationFormCubit>().updateFields(
|
context.read<OperationFormCubit>().updateFields(
|
||||||
reference: _referenceController.text,
|
reference: _referenceController.text,
|
||||||
note: _noteController.text,
|
note: _noteController.text,
|
||||||
subtype: _freeTextSubtypeController.text,
|
subType: _freeTextSubtypeController.text,
|
||||||
description: _freeTextDescriptionController.text,
|
description: _freeTextDescriptionController.text,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -99,10 +104,11 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
required bool keepAdding,
|
required bool keepAdding,
|
||||||
}) {
|
}) {
|
||||||
if (_formKey.currentState!.validate()) {
|
if (_formKey.currentState!.validate()) {
|
||||||
|
// Se non stiamo facendo "Salva e Aggiungi Altro", il nostro intento è chiudere
|
||||||
|
_isClosingIntent = !keepAdding;
|
||||||
|
|
||||||
_flushControllersToCubit();
|
_flushControllersToCubit();
|
||||||
// Aggiorniamo prima lo stato bersaglio nel cubit
|
|
||||||
context.read<OperationFormCubit>().updateFields(status: targetStatus);
|
context.read<OperationFormCubit>().updateFields(status: targetStatus);
|
||||||
// Poi chiamiamo il salvataggio
|
|
||||||
context.read<OperationFormCubit>().saveOperation(
|
context.read<OperationFormCubit>().saveOperation(
|
||||||
targetStatus: targetStatus,
|
targetStatus: targetStatus,
|
||||||
keepAdding: keepAdding,
|
keepAdding: keepAdding,
|
||||||
@@ -110,11 +116,15 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _generateIdForQr() async {
|
// RINOMINATA E RESA SICURA
|
||||||
|
Future<String?> _ensureEntitySaved() async {
|
||||||
if (!_formKey.currentState!.validate()) return null;
|
if (!_formKey.currentState!.validate()) return null;
|
||||||
|
|
||||||
|
// 🥷 Diciamo esplicitamente al sistema che NON vogliamo uscire dalla pagina
|
||||||
|
_isClosingIntent = false;
|
||||||
|
|
||||||
_flushControllersToCubit();
|
_flushControllersToCubit();
|
||||||
|
|
||||||
// Lo leggiamo pulito pulito dal context, perché c'è!
|
|
||||||
final attachmentsBloc = context.read<AttachmentsBloc>();
|
final attachmentsBloc = context.read<AttachmentsBloc>();
|
||||||
|
|
||||||
final newId = await context.read<OperationFormCubit>().saveOperationDraft();
|
final newId = await context.read<OperationFormCubit>().saveOperationDraft();
|
||||||
@@ -149,8 +159,15 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
if (state.status == OperationFormStatus.ready && !_isInitialized) {
|
if (state.status == OperationFormStatus.ready && !_isInitialized) {
|
||||||
_syncTextControllers(state.operation);
|
_syncTextControllers(state.operation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🥷 ORA POPPA SOLO SE L'INTENTO ERA QUELLO DI USCIRE!
|
||||||
if (state.status == OperationFormStatus.success) {
|
if (state.status == OperationFormStatus.success) {
|
||||||
Navigator.of(context).pop();
|
if (_isClosingIntent) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} else {
|
||||||
|
// È stato un salvataggio background (Pick Files o QR).
|
||||||
|
// Non facciamo nulla, l'utente resta sulla pagina e i file vengono caricati!
|
||||||
|
}
|
||||||
} else if (state.status == OperationFormStatus.successAndAddAnother) {
|
} else if (state.status == OperationFormStatus.successAndAddAnother) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -398,8 +415,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
labelText: 'Riferimento (es. Telefono, Targa...)',
|
labelText: 'Riferimento (es. Telefono, Targa...)',
|
||||||
prefixIcon: Icon(Icons.tag),
|
prefixIcon: Icon(Icons.tag),
|
||||||
),
|
),
|
||||||
validator: (v) =>
|
|
||||||
v == null || v.isEmpty ? 'Inserisci un riferimento' : null,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -483,36 +498,39 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
icon: Icons.design_services,
|
icon: Icons.design_services,
|
||||||
themeColor: Colors.deepOrange,
|
themeColor: Colors.deepOrange,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
SingleChildScrollView(
|
||||||
children: [
|
scrollDirection: Axis.horizontal,
|
||||||
ChoiceChip(
|
child: Row(
|
||||||
label: const Text('Privato (Domestico)'),
|
children: [
|
||||||
selected: !state.operation.isBusiness,
|
ChoiceChip(
|
||||||
selectedColor: Colors.blue.withValues(alpha: 0.2),
|
label: const Text('Privato (Domestico)'),
|
||||||
checkmarkColor: Colors.blue.shade700,
|
selected: !state.operation.isBusiness,
|
||||||
onSelected: (selected) {
|
selectedColor: Colors.blue.withValues(alpha: 0.2),
|
||||||
if (selected) {
|
checkmarkColor: Colors.blue.shade700,
|
||||||
context.read<OperationFormCubit>().updateFields(
|
onSelected: (selected) {
|
||||||
isBusiness: false,
|
if (selected) {
|
||||||
);
|
context.read<OperationFormCubit>().updateFields(
|
||||||
}
|
isBusiness: false,
|
||||||
},
|
);
|
||||||
),
|
}
|
||||||
const SizedBox(width: 12),
|
},
|
||||||
ChoiceChip(
|
),
|
||||||
label: const Text('Business (P.IVA)'),
|
const SizedBox(width: 12),
|
||||||
selected: state.operation.isBusiness,
|
ChoiceChip(
|
||||||
selectedColor: Colors.orange.withValues(alpha: 0.2),
|
label: const Text('Business (P.IVA)'),
|
||||||
checkmarkColor: Colors.orange.shade700,
|
selected: state.operation.isBusiness,
|
||||||
onSelected: (selected) {
|
selectedColor: Colors.orange.withValues(alpha: 0.2),
|
||||||
if (selected) {
|
checkmarkColor: Colors.orange.shade700,
|
||||||
context.read<OperationFormCubit>().updateFields(
|
onSelected: (selected) {
|
||||||
isBusiness: true,
|
if (selected) {
|
||||||
);
|
context.read<OperationFormCubit>().updateFields(
|
||||||
}
|
isBusiness: true,
|
||||||
},
|
);
|
||||||
),
|
}
|
||||||
],
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Wrap(
|
Wrap(
|
||||||
@@ -524,8 +542,24 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
selected: state.operation.type == type,
|
selected: state.operation.type == type,
|
||||||
onSelected: (selected) {
|
onSelected: (selected) {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
context.read<OperationFormCubit>().setTypeWithSmartDefault(
|
// 1. Recuperiamo i provider caricati in memoria
|
||||||
type,
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -607,7 +641,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
|
|||||||
parentType: AttachmentParentType.operation,
|
parentType: AttachmentParentType.operation,
|
||||||
parentId: state.operation.id,
|
parentId: state.operation.id,
|
||||||
titleForUpload: state.operation.customer?.name ?? 'Nuova Pratica',
|
titleForUpload: state.operation.customer?.name ?? 'Nuova Pratica',
|
||||||
onGenerateIdForQr: _generateIdForQr,
|
onEnsureEntitySaved: _ensureEntitySaved, // 🥷 Il nuovo nome!
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
|||||||
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
|
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
import 'package:flux/features/operations/models/operation_model.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
// Importa i tuoi modelli e cubit
|
|
||||||
|
|
||||||
class OperationListScreen extends StatefulWidget {
|
class OperationListScreen extends StatefulWidget {
|
||||||
const OperationListScreen({super.key});
|
const OperationListScreen({super.key});
|
||||||
@@ -17,17 +16,39 @@ class OperationListScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _OperationListScreenState extends State<OperationListScreen> {
|
class _OperationListScreenState extends State<OperationListScreen> {
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
|
||||||
|
// Set per gestire le Bulk Actions (Selezione multipla)
|
||||||
|
final Set<String> _selectedOperationIds = {};
|
||||||
|
bool get _isSelectionMode => _selectedOperationIds.isNotEmpty;
|
||||||
|
|
||||||
|
// Flag per mostrare/nascondere la barra di ricerca integrata nell'AppBar
|
||||||
|
bool _showSearchBar = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Agganciamo il listener per la paginazione (Scroll Infinito)
|
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
|
// Primo caricamento: partiamo da pagina 1
|
||||||
|
// (Il Cubit deciderà se fare il boot iniziale o se c'era già roba in cache)
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final isDesktop = MediaQuery.sizeOf(context).width >= 900;
|
||||||
|
if (isDesktop) {
|
||||||
|
context.read<OperationListCubit>().loadSpecificPageDesktop(1);
|
||||||
|
} else {
|
||||||
|
context.read<OperationListCubit>().loadNextPageMobile(refresh: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
|
final isDesktop = MediaQuery.sizeOf(context).width >= 900;
|
||||||
|
// 🥷 COMPORTAMENTO IBRIDO: Lo scroll infinito si attiva SOLO su mobile
|
||||||
|
if (isDesktop) return;
|
||||||
|
|
||||||
if (_isBottom) {
|
if (_isBottom) {
|
||||||
context.read<OperationListCubit>().loadOperations();
|
context.read<OperationListCubit>().loadNextPageMobile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,66 +56,170 @@ class _OperationListScreenState extends State<OperationListScreen> {
|
|||||||
if (!_scrollController.hasClients) return false;
|
if (!_scrollController.hasClients) return false;
|
||||||
final maxScroll = _scrollController.position.maxScrollExtent;
|
final maxScroll = _scrollController.position.maxScrollExtent;
|
||||||
final currentScroll = _scrollController.offset;
|
final currentScroll = _scrollController.offset;
|
||||||
// Carica quando mancano 200px alla fine
|
|
||||||
return currentScroll >= (maxScroll * 0.9);
|
return currentScroll >= (maxScroll * 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
|
_searchController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _toggleSelection(String id) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedOperationIds.contains(id)) {
|
||||||
|
_selectedOperationIds.remove(id);
|
||||||
|
} else {
|
||||||
|
_selectedOperationIds.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSelection() {
|
||||||
|
setState(() {
|
||||||
|
_selectedOperationIds.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isDesktop = MediaQuery.sizeOf(context).width >= 900;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
// --- APP BAR DINAMICA E INTEGRATA ---
|
||||||
title: const Text("Gestione Servizi"),
|
appBar: _isSelectionMode
|
||||||
elevation: 0,
|
? AppBar(
|
||||||
actions: [
|
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||||
IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: () {
|
onPressed: _clearSelection,
|
||||||
// Qui potrai implementare una barra di ricerca
|
),
|
||||||
},
|
title: Text("${_selectedOperationIds.length} selezionate"),
|
||||||
),
|
actions: [
|
||||||
],
|
IconButton(
|
||||||
),
|
icon: const Icon(Icons.edit_note),
|
||||||
|
tooltip: 'Cambia Stato Massivo',
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Integrare bottom sheet per azioni massive
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: AppBar(
|
||||||
|
title: _showSearchBar
|
||||||
|
? TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Cerca per cliente, nota o riferimento...',
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
onChanged: (text) {
|
||||||
|
context.read<OperationListCubit>().updateFilters(
|
||||||
|
text: text,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: const Text("Gestione Servizi"),
|
||||||
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_showSearchBar ? Icons.close : Icons.search),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_showSearchBar = !_showSearchBar;
|
||||||
|
if (!_showSearchBar) {
|
||||||
|
_searchController.clear();
|
||||||
|
context.read<OperationListCubit>().clearFilters();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!isDesktop) // Il pull-to-refresh c'è già su mobile, su desktop mettiamo un tasto manuale
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.filter_list),
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Bottone Filtri Avanzati (es. DateRange Picker)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- CORPO RESPONSIVO ---
|
||||||
body: BlocBuilder<OperationListCubit, OperationListState>(
|
body: BlocBuilder<OperationListCubit, OperationListState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
// 1. Stato di caricamento iniziale
|
|
||||||
if (state.status == OperationListStatus.loading &&
|
if (state.status == OperationListStatus.loading &&
|
||||||
state.operations.isEmpty) {
|
state.operations.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Lista vuota
|
|
||||||
if (state.operations.isEmpty) {
|
if (state.operations.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Text("Nessuna pratica trovata."),
|
const Text("Nessuna pratica trovata."),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 12),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => context
|
onPressed: () => isDesktop
|
||||||
.read<OperationListCubit>()
|
? context
|
||||||
.loadOperations(refresh: true),
|
.read<OperationListCubit>()
|
||||||
child: const Text("Riprova"),
|
.loadSpecificPageDesktop(1)
|
||||||
|
: context.read<OperationListCubit>().loadNextPageMobile(
|
||||||
|
refresh: true,
|
||||||
|
),
|
||||||
|
child: const Text("Ricarica"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. La Lista (con Pull-to-refresh)
|
// 🥷 SCENARIO DESKTOP: Griglia + Barra di Paginazione Gmail-Style
|
||||||
|
if (isDesktop) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: GridView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent:
|
||||||
|
420, // Larghezza bilanciata per le card su desktop
|
||||||
|
mainAxisExtent:
|
||||||
|
175, // Altezza controllata per evitare buchi bianchi
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
),
|
||||||
|
itemCount: state.operations.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final operation = state.operations[index];
|
||||||
|
return _buildResponsiveCard(operation);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildDesktopPaginationFooter(
|
||||||
|
state,
|
||||||
|
), // La barra in fondo stile Gmail
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🥷 SCENARIO MOBILE: ListView con Infinite Scroll e Pull-to-Refresh
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => context.read<OperationListCubit>().loadOperations(
|
onRefresh: () => context
|
||||||
refresh: true,
|
.read<OperationListCubit>()
|
||||||
),
|
.loadNextPageMobile(refresh: true),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: 80,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
top: 8,
|
||||||
|
),
|
||||||
itemCount: state.hasReachedMax
|
itemCount: state.hasReachedMax
|
||||||
? state.operations.length
|
? state.operations.length
|
||||||
: state.operations.length + 1,
|
: state.operations.length + 1,
|
||||||
@@ -109,98 +234,453 @@ class _OperationListScreenState extends State<OperationListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final operation = state.operations[index];
|
final operation = state.operations[index];
|
||||||
return _buildOperationCard(context, operation);
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: _buildResponsiveCard(operation),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
|
||||||
onPressed: () async {
|
floatingActionButton: _isSelectionMode
|
||||||
StaffMemberModel? createdBy = await getStaffMember(context);
|
? null
|
||||||
if (createdBy == null || !context.mounted) return;
|
: FloatingActionButton(
|
||||||
|
onPressed: () async {
|
||||||
|
StaffMemberModel? createdBy = await getStaffMember(context);
|
||||||
|
if (createdBy == null || !context.mounted) return;
|
||||||
|
context.pushNamed(
|
||||||
|
Routes.operationForm,
|
||||||
|
pathParameters: {'id': 'new'},
|
||||||
|
extra: (createdBy: createdBy, operation: null),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- COSTRUZIONE DELLA COMPONENTISTICA DETTAGLIATA ---
|
||||||
|
|
||||||
|
Widget _buildResponsiveCard(OperationModel operation) {
|
||||||
|
final isSelected = _selectedOperationIds.contains(operation.id);
|
||||||
|
return _RichOperationCard(
|
||||||
|
operation: operation,
|
||||||
|
isSelected: isSelected,
|
||||||
|
isSelectionMode: _isSelectionMode,
|
||||||
|
onTap: () {
|
||||||
|
if (_isSelectionMode) {
|
||||||
|
_toggleSelection(operation.id!);
|
||||||
|
} else {
|
||||||
context.pushNamed(
|
context.pushNamed(
|
||||||
Routes.operationForm,
|
Routes.operationForm,
|
||||||
pathParameters: {'id': 'new'},
|
extra: (createdBy: null, operation: operation),
|
||||||
extra: (createdBy: createdBy, operation: null),
|
pathParameters: {'id': operation.id!},
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
child: const Icon(Icons.add),
|
},
|
||||||
),
|
onLongPress: () => _toggleSelection(operation.id!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildOperationCard(BuildContext context, OperationModel operation) {
|
// 🥷 LA BARRA DI PAGINAZIONE DESKTOP (Stile Gmail / Typesense)
|
||||||
return Card(
|
Widget _buildDesktopPaginationFooter(OperationListState state) {
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
final theme = Theme.of(context);
|
||||||
elevation: 2,
|
final cubit = context.read<OperationListCubit>();
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
child: ListTile(
|
// Calcolo intervallo visualizzato (es. 1-25 di 140)
|
||||||
contentPadding: const EdgeInsets.all(12),
|
final fromItem = ((state.currentPage - 1) * state.itemsPerPage) + 1;
|
||||||
title: Row(
|
final toItem =
|
||||||
children: [
|
DateUtils.isSameDay(DateTime.now(), DateTime.now()) // segnaposto logico
|
||||||
Expanded(
|
? (fromItem + state.operations.length - 1)
|
||||||
child: Text(
|
: fromItem;
|
||||||
operation.customer?.name ?? "Cliente sconosciuto",
|
|
||||||
style: const TextStyle(
|
return Container(
|
||||||
fontWeight: FontWeight.bold,
|
height: 56,
|
||||||
fontSize: 16,
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
border: Border(top: BorderSide(color: theme.dividerColor, width: 0.5)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
// Info totali a sinistra
|
||||||
|
Text(
|
||||||
|
"$fromItem-$toItem di ${state.totalItems} pratiche totali",
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Controlli di navigazione a destra
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Prima Pagina
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.first_page),
|
||||||
|
onPressed: state.currentPage > 1
|
||||||
|
? () => cubit.loadSpecificPageDesktop(1)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
// Pagina Precedente
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.chevron_left),
|
||||||
|
onPressed: state.currentPage > 1
|
||||||
|
? () => cubit.loadSpecificPageDesktop(state.currentPage - 1)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Indicatore numerico centrale impacchettato
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: Text(
|
||||||
|
"Pagina ${state.currentPage} di ${state.totalPages}",
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
// Pagina Successiva
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.chevron_right),
|
||||||
|
onPressed: state.currentPage < state.totalPages
|
||||||
|
? () => cubit.loadSpecificPageDesktop(state.currentPage + 1)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
// Ultima Pagina
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.last_page),
|
||||||
|
onPressed: state.currentPage < state.totalPages
|
||||||
|
? () => cubit.loadSpecificPageDesktop(state.totalPages)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 🥷 3. LA CARD RICCA, REATTIVA E DEFINITIVA (Quella revisionata insieme)
|
||||||
|
// =========================================================================
|
||||||
|
class _RichOperationCard extends StatelessWidget {
|
||||||
|
final OperationModel operation;
|
||||||
|
final bool isSelected;
|
||||||
|
final bool isSelectionMode;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
|
const _RichOperationCard({
|
||||||
|
required this.operation,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.isSelectionMode,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
Color _getStatusColor(OperationStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case OperationStatus.success:
|
||||||
|
return Colors.green;
|
||||||
|
case OperationStatus.waitingForAction:
|
||||||
|
case OperationStatus.draft:
|
||||||
|
return Colors.orange;
|
||||||
|
case OperationStatus.waitingForSupport:
|
||||||
|
return Colors.blue;
|
||||||
|
case OperationStatus.failure:
|
||||||
|
return Colors.grey.shade800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getTypeColor(String type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'FIN':
|
||||||
|
return Colors.deepPurple;
|
||||||
|
case 'TELEPASS':
|
||||||
|
return Colors.yellow.shade700;
|
||||||
|
case 'ENERGY':
|
||||||
|
return Colors.amber.shade700;
|
||||||
|
case 'ENTERTAINMENT':
|
||||||
|
return Colors.pinkAccent;
|
||||||
|
case 'AL':
|
||||||
|
case 'MNP':
|
||||||
|
return Colors.indigo;
|
||||||
|
case 'NIP':
|
||||||
|
case 'FWA':
|
||||||
|
return Colors.cyan;
|
||||||
|
default:
|
||||||
|
return Colors.blueGrey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final statusColor = _getStatusColor(operation.status);
|
||||||
|
final typeColor = _getTypeColor(operation.type);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero, // Gestito dai margini dei padri (griglia/lista)
|
||||||
|
elevation: isSelected ? 4 : 1,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(
|
||||||
|
color: isSelected ? theme.colorScheme.primary : Colors.transparent,
|
||||||
|
width: 2,
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: InkWell(
|
||||||
children: [
|
borderRadius: BorderRadius.circular(12),
|
||||||
const SizedBox(height: 4),
|
onTap: onTap,
|
||||||
Text(
|
onLongPress: onLongPress,
|
||||||
"Pratica: ${operation.reference} • ${operation.createdAt?.day}/${operation.createdAt?.month}/${operation.createdAt?.year}",
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primaryContainer.withValues(alpha: 0.15)
|
||||||
|
: null,
|
||||||
|
// 🥷 COERENZA 100%: Banda laterale legata allo status per eliminare i malintesi cromatici
|
||||||
|
border: Border(left: BorderSide(color: statusColor, width: 6)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
padding: const EdgeInsets.all(12),
|
||||||
Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(operation.type),
|
// --- LINEA HEADER ---
|
||||||
const SizedBox(width: 8),
|
Row(
|
||||||
_buildOperationStatus(operation.status),
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
if (isSelectionMode)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 18,
|
||||||
|
width: 18,
|
||||||
|
child: Checkbox(
|
||||||
|
value: isSelected,
|
||||||
|
onChanged: (_) => onTap(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
(operation.reference.isEmpty)
|
||||||
|
? 'Senza Riferimento'
|
||||||
|
: operation.reference,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
operation.createdAt != null
|
||||||
|
? "${operation.createdAt!.day.toString().padLeft(2, '0')}/${operation.createdAt!.month.toString().padLeft(2, '0')}/${operation.createdAt!.year}"
|
||||||
|
: '',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// --- LINEA CENTRALE: CLIENTE + INSERTO OPERATIVO ---
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
operation.customer?.name ?? "Cliente sconosciuto",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
// 🥷 IL RE DEL SERVICE: Il tipo operazione svetta con box e contrasto ad hoc
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: typeColor.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(
|
||||||
|
color: typeColor.withValues(alpha: 0.25),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (_getIconForType(
|
||||||
|
operation.type,
|
||||||
|
operation.subType,
|
||||||
|
) !=
|
||||||
|
null) ...[
|
||||||
|
Icon(
|
||||||
|
_getIconForType(
|
||||||
|
operation.type,
|
||||||
|
operation.subType,
|
||||||
|
),
|
||||||
|
size: 13,
|
||||||
|
color: typeColor,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
(operation.subType != null &&
|
||||||
|
operation.subType!.isNotEmpty)
|
||||||
|
? operation.subType!
|
||||||
|
: operation.type,
|
||||||
|
style: TextStyle(
|
||||||
|
color: typeColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// --- LINEA DEI TAG TECNICI ---
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
// Tag Target Espanso (Privato / Business)
|
||||||
|
_MiniChip(
|
||||||
|
label: operation.isBusiness ? 'Business' : 'Privato',
|
||||||
|
icon: operation.isBusiness
|
||||||
|
? Icons.business
|
||||||
|
: Icons.person,
|
||||||
|
color: operation.isBusiness ? Colors.indigo : Colors.teal,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Tag Gestore (Agganciato dinamicamente al displayColor generato dall'esadecimale del DB!)
|
||||||
|
if (operation.provider != null)
|
||||||
|
_MiniChip(
|
||||||
|
label: operation.provider?.name ?? 'Gestore',
|
||||||
|
color:
|
||||||
|
operation.provider?.displayColor ?? Colors.blueGrey,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Specifiche addizionali del Finanziamento
|
||||||
|
if (operation.type == 'Fin' && operation.modelId != null)
|
||||||
|
_MiniChip(
|
||||||
|
label: operation.modelDisplayName ?? 'Prodotto',
|
||||||
|
icon: Icons.devices,
|
||||||
|
color: Colors.deepPurple,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const Spacer(),
|
||||||
|
|
||||||
|
// --- FOOTER CARD: AGENTE + CHIP STATO ---
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.support_agent,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
operation.staffDisplayName ?? 'Assegnato',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
operation.status.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () => context.pushNamed(
|
|
||||||
Routes.operationForm,
|
|
||||||
extra: (createdBy: null, operation: operation),
|
|
||||||
pathParameters: {'id': operation.id!},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildOperationStatus(OperationStatus status) {
|
IconData? _getIconForType(String type, String? subtype) {
|
||||||
Color color;
|
if (type == 'Energy') {
|
||||||
switch (status) {
|
if (subtype?.toLowerCase() == 'luce') return Icons.bolt;
|
||||||
case OperationStatus.failure:
|
if (subtype?.toLowerCase() == 'gas') return Icons.local_fire_department;
|
||||||
color = Colors.grey.shade800;
|
|
||||||
break;
|
|
||||||
case OperationStatus.waitingForAction || OperationStatus.draft:
|
|
||||||
color = Colors.orange;
|
|
||||||
break;
|
|
||||||
case OperationStatus.success:
|
|
||||||
color = Colors.green;
|
|
||||||
break;
|
|
||||||
case OperationStatus.waitingForSupport:
|
|
||||||
color = Colors.blue;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
return Chip(
|
return null;
|
||||||
label: Text("BOZZA", style: TextStyle(fontSize: 10, color: Colors.white)),
|
}
|
||||||
backgroundColor: color,
|
}
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
);
|
// Micro Widget di supporto per i tag interni
|
||||||
}
|
class _MiniChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
void startNewOperation(BuildContext context) {
|
final IconData? icon;
|
||||||
context.pushNamed('operation-form', pathParameters: {'id': 'new'});
|
final Color color;
|
||||||
|
|
||||||
|
const _MiniChip({required this.label, this.icon, required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.08),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.25)),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, size: 11, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/widgets/shared_forms/model_section.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/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_model_extensions.dart';
|
||||||
import 'package:flux/features/master_data/providers/models/provider_role.dart';
|
|
||||||
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
|
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
|
||||||
import 'package:flux/features/operations/models/operation_model.dart';
|
import 'package:flux/features/operations/models/operation_model.dart';
|
||||||
|
|
||||||
@@ -23,34 +22,6 @@ class OperationDetailsSection extends StatelessWidget {
|
|||||||
required this.durationQuickPicks,
|
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) {
|
void _showProviderModal(BuildContext context, String operationType) {
|
||||||
final OperationFormCubit cubit = context.read<OperationFormCubit>();
|
final OperationFormCubit cubit = context.read<OperationFormCubit>();
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
@@ -92,14 +63,9 @@ class OperationDetailsSection extends StatelessWidget {
|
|||||||
return const Center(child: CircularProgressIndicator());
|
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 filteredProviders = state.providers.where((p) {
|
||||||
final isMatch = _doesProviderMatchOperationType(
|
return p.supportsOperation(operationType) && p.isActive;
|
||||||
p,
|
|
||||||
operationType,
|
|
||||||
);
|
|
||||||
return isMatch &&
|
|
||||||
p.isActive; // Mostriamo solo quelli attivi!
|
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
if (filteredProviders.isEmpty) {
|
if (filteredProviders.isEmpty) {
|
||||||
@@ -136,10 +102,9 @@ class OperationDetailsSection extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.read<OperationFormCubit>().updateFields(
|
context
|
||||||
providerId: provider.id,
|
.read<OperationFormCubit>()
|
||||||
providerDisplayName: provider.name,
|
.updateProvider(provider);
|
||||||
);
|
|
||||||
Navigator.pop(modalContext);
|
Navigator.pop(modalContext);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -168,19 +133,18 @@ class OperationDetailsSection extends StatelessWidget {
|
|||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('Seleziona Gestore'),
|
title: const Text('Seleziona Gestore'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
(currentOp?.providerDisplayName != null &&
|
(currentOp?.provider != null)
|
||||||
currentOp!.providerDisplayName!.isNotEmpty)
|
? currentOp!.provider!.name
|
||||||
? currentOp!.providerDisplayName!
|
|
||||||
: 'Nessun gestore selezionato',
|
: 'Nessun gestore selezionato',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color:
|
color:
|
||||||
(currentOp?.providerId == null ||
|
(currentOp?.provider == null ||
|
||||||
currentOp!.providerId!.isEmpty)
|
currentOp!.provider!.name.isEmpty)
|
||||||
? Colors.grey
|
? Colors.grey
|
||||||
: null,
|
: null,
|
||||||
fontWeight:
|
fontWeight:
|
||||||
(currentOp?.providerId == null ||
|
(currentOp?.provider == null ||
|
||||||
currentOp!.providerId!.isEmpty)
|
currentOp!.provider!.name.isEmpty)
|
||||||
? FontWeight.normal
|
? FontWeight.normal
|
||||||
: FontWeight.bold,
|
: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -198,8 +162,8 @@ class OperationDetailsSection extends StatelessWidget {
|
|||||||
if (currentType == 'Energy') ...[
|
if (currentType == 'Energy') ...[
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue:
|
initialValue:
|
||||||
(currentOp?.subtype != null && currentOp!.subtype!.isNotEmpty)
|
(currentOp?.subType != null && currentOp!.subType!.isNotEmpty)
|
||||||
? currentOp!.subtype
|
? currentOp!.subType
|
||||||
: null,
|
: null,
|
||||||
decoration: const InputDecoration(labelText: 'Dettaglio Fornitura'),
|
decoration: const InputDecoration(labelText: 'Dettaglio Fornitura'),
|
||||||
items: [
|
items: [
|
||||||
@@ -208,7 +172,7 @@ class OperationDetailsSection extends StatelessWidget {
|
|||||||
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
if (val != null) {
|
if (val != null) {
|
||||||
context.read<OperationFormCubit>().updateFields(subtype: val);
|
context.read<OperationFormCubit>().updateFields(subType: val);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
108
lib/features/settings/blocs/reminder_defaults_cubit.dart
Normal file
108
lib/features/settings/blocs/reminder_defaults_cubit.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
lib/features/settings/blocs/reminder_defaults_state.dart
Normal file
33
lib/features/settings/blocs/reminder_defaults_state.dart
Normal 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];
|
||||||
|
}
|
||||||
63
lib/features/settings/data/settings_repository.dart
Normal file
63
lib/features/settings/data/settings_repository.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,151 +8,163 @@ class DocumentSequenceSection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final year = DateTime.now().year;
|
|
||||||
|
|
||||||
return BlocBuilder<DocumentSequenceCubit, DocumentSequenceState>(
|
return BlocBuilder<DocumentSequenceCubit, DocumentSequenceState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.status == DocumentSequenceStatus.loading) {
|
if (state.status == DocumentSequenceStatus.loading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
return LayoutBuilder(
|
||||||
return Column(
|
builder: ((context, constraints) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final isLargeScreen = constraints.maxWidth >= 600;
|
||||||
children: [
|
return _buildMainContent(
|
||||||
Padding(
|
state: state,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
isLargeScreen: isLargeScreen,
|
||||||
child: Text(
|
context: context,
|
||||||
"Protocolli e Numerazione",
|
);
|
||||||
style: Theme.of(
|
}),
|
||||||
context,
|
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Invece di mappare state.sequences, mappiamo i documenti supportati
|
|
||||||
...DocumentType.values.map((docType) {
|
|
||||||
// Cerchiamo se c'è già una configurazione nello stato per questo documento
|
|
||||||
final existingList = state.sequences
|
|
||||||
.where((s) => s.docType == docType.name)
|
|
||||||
.toList();
|
|
||||||
final existingSeq = existingList.isNotEmpty
|
|
||||||
? existingList.first
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Se esiste usiamo i suoi valori, altrimenti i default
|
|
||||||
final prefix = existingSeq?.prefix ?? docType.defaultPrefix;
|
|
||||||
final nextValue = existingSeq?.nextValue ?? 1;
|
|
||||||
|
|
||||||
// Anteprima dinamica (aggiornata a 4 zeri come nel DB!)
|
|
||||||
final preview =
|
|
||||||
"${prefix.isNotEmpty ? '$prefix-' : ''}$year-${nextValue.toString().padLeft(4, '0')}";
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
docType.label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.blue,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: TextFormField(
|
|
||||||
initialValue: prefix,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Prefisso',
|
|
||||||
hintText: 'es. TCK',
|
|
||||||
),
|
|
||||||
onChanged: (val) => context
|
|
||||||
.read<DocumentSequenceCubit>()
|
|
||||||
.updateLocalSequence(
|
|
||||||
docType.name,
|
|
||||||
prefix: val,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: TextFormField(
|
|
||||||
initialValue: nextValue.toString(),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Prossimo Numero',
|
|
||||||
),
|
|
||||||
onChanged: (val) => context
|
|
||||||
.read<DocumentSequenceCubit>()
|
|
||||||
.updateLocalSequence(
|
|
||||||
docType.name,
|
|
||||||
nextValue: int.tryParse(val) ?? 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors
|
|
||||||
.grey
|
|
||||||
.shade100, // Se hai un tema scuro potresti voler usare Theme.of(context).colorScheme.surfaceContainer
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.visibility,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
"Anteprima prossimo: ",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors
|
|
||||||
.grey
|
|
||||||
.shade700, // Idem per la dark mode
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
preview,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () =>
|
|
||||||
context.read<DocumentSequenceCubit>().saveSequences(),
|
|
||||||
icon: const Icon(Icons.save),
|
|
||||||
label: const Text("SALVA PROTOCOLLI"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildMainContent({
|
||||||
|
required BuildContext context,
|
||||||
|
required DocumentSequenceState state,
|
||||||
|
required bool isLargeScreen,
|
||||||
|
}) {
|
||||||
|
final year = DateTime.now().year;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: Text(
|
||||||
|
"Protocolli e Numerazione",
|
||||||
|
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Invece di mappare state.sequences, mappiamo i documenti supportati
|
||||||
|
...DocumentType.values.map((docType) {
|
||||||
|
// Cerchiamo se c'è già una configurazione nello stato per questo documento
|
||||||
|
final existingList = state.sequences
|
||||||
|
.where((s) => s.docType == docType.name)
|
||||||
|
.toList();
|
||||||
|
final existingSeq = existingList.isNotEmpty
|
||||||
|
? existingList.first
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Se esiste usiamo i suoi valori, altrimenti i default
|
||||||
|
final prefix = existingSeq?.prefix ?? docType.defaultPrefix;
|
||||||
|
final nextValue = existingSeq?.nextValue ?? 1;
|
||||||
|
|
||||||
|
// Anteprima dinamica (aggiornata a 4 zeri come nel DB!)
|
||||||
|
final preview =
|
||||||
|
"${prefix.isNotEmpty ? '$prefix-' : ''}$year-${nextValue.toString().padLeft(4, '0')}";
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
docType.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: prefix,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Prefisso',
|
||||||
|
hintText: 'es. TCK',
|
||||||
|
),
|
||||||
|
onChanged: (val) => context
|
||||||
|
.read<DocumentSequenceCubit>()
|
||||||
|
.updateLocalSequence(docType.name, prefix: val),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: nextValue.toString(),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Prossimo Numero',
|
||||||
|
),
|
||||||
|
onChanged: (val) => context
|
||||||
|
.read<DocumentSequenceCubit>()
|
||||||
|
.updateLocalSequence(
|
||||||
|
docType.name,
|
||||||
|
nextValue: int.tryParse(val) ?? 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.visibility,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
"Anteprima prossimo: ",
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
Colors.grey.shade700, // Idem per la dark mode
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
preview,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
context.read<DocumentSequenceCubit>().saveSequences(),
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
label: const Text("SALVA PROTOCOLLI"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
272
lib/features/settings/ui/reminder_settings_screen.dart
Normal file
272
lib/features/settings/ui/reminder_settings_screen.dart
Normal 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!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,15 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
|
_settingsSection('Utente', [
|
||||||
|
_settingsTile(
|
||||||
|
title: 'Impostazioni Promemoria',
|
||||||
|
icon: Icons.notifications,
|
||||||
|
subtitle: 'Notifiche predefinite',
|
||||||
|
context: context,
|
||||||
|
onTap: () => context.pushNamed(Routes.reminderSettings),
|
||||||
|
),
|
||||||
|
]),
|
||||||
_settingsSection('Azienda', [
|
_settingsSection('Azienda', [
|
||||||
_settingsTile(
|
_settingsTile(
|
||||||
title: 'Impostazioni Azienda',
|
title: 'Impostazioni Azienda',
|
||||||
@@ -54,9 +63,11 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.person, color: FluxColors.primaryBlue),
|
const Icon(Icons.person, color: FluxColors.primaryBlue),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Flexible(
|
||||||
'Modalità utente singolo (dispositivo personale)',
|
child: Text(
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
'Modalità utente singolo (dispositivo personale)',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -81,6 +92,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
onTap: () => context.pushNamed(Routes.themeSettings),
|
onTap: () => context.pushNamed(Routes.themeSettings),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => context.read<SessionCubit>().signOut(),
|
onPressed: () => context.read<SessionCubit>().signOut(),
|
||||||
306
lib/features/tasks/blocs/task_form_cubit.dart
Normal file
306
lib/features/tasks/blocs/task_form_cubit.dart
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
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_reminder_config.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_status.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
part 'task_form_state.dart';
|
||||||
|
|
||||||
|
class TaskFormCubit extends Cubit<TaskFormState> {
|
||||||
|
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({
|
||||||
|
String? initialTaskId, // <-- RIPRISTINATO PER DEEP LINK
|
||||||
|
TaskModel? existingTask,
|
||||||
|
List<StaffMemberModel>? allStaff,
|
||||||
|
}) : _preloadedStaff = allStaff,
|
||||||
|
super(const TaskFormState()) {
|
||||||
|
// Avviamo l'inizializzazione centralizzata (gestisce sia mem, sia deep link, sia nuovo)
|
||||||
|
initForm(initialTaskId: initialTaskId, existingTask: existingTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _companyId => _sessionCubit.state.company!.id!;
|
||||||
|
StaffMemberModel get _currentUser => _sessionCubit.state.currentStaffMember!;
|
||||||
|
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));
|
||||||
|
try {
|
||||||
|
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,
|
||||||
|
taskStatus: task.status,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LOGICA GESTIONE STAFF (GLOBAL STAFF / STORE STAFF) ---
|
||||||
|
Future<void> _loadAndGroupStaff() async {
|
||||||
|
final List<StaffMemberModel> staffList;
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
);
|
||||||
|
if (!belongsToCurrentStore) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staff.assignedStores.isEmpty) {
|
||||||
|
grouped.putIfAbsent('Direzione / Senza Sede', () => []).add(staff);
|
||||||
|
} else {
|
||||||
|
for (var store in staff.assignedStores) {
|
||||||
|
if (!state.isGlobal && store.id != _currentStoreId) continue;
|
||||||
|
final storeName = store.name;
|
||||||
|
grouped.putIfAbsent(storeName, () => []).add(staff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: _currentUser.id!,
|
||||||
|
);
|
||||||
|
final initialReminders = defaults
|
||||||
|
.map(
|
||||||
|
(d) => TaskReminderConfig(
|
||||||
|
minutesBefore: d.minutesBefore,
|
||||||
|
channel: d.channel,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
emit(state.copyWith(reminders: initialReminders));
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
reminders: const [
|
||||||
|
TaskReminderConfig(minutesBefore: 15, channel: 'push'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadExistingTaskReminders(String taskId) async {
|
||||||
|
try {
|
||||||
|
final existingConfigs = await _repository.fetchPersonalReminders(
|
||||||
|
taskId: taskId,
|
||||||
|
staffId: _currentUser.id!,
|
||||||
|
);
|
||||||
|
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) {
|
||||||
|
final updated = List<String>.from(state.selectedStaffIds);
|
||||||
|
final storeStaff = state.groupedAvailableStaff[storeName] ?? [];
|
||||||
|
|
||||||
|
for (var staff in storeStaff) {
|
||||||
|
if (staff.id == null) continue;
|
||||||
|
if (selectAll) {
|
||||||
|
if (!updated.contains(staff.id)) updated.add(staff.id!);
|
||||||
|
} else {
|
||||||
|
updated.remove(staff.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(state.copyWith(selectedStaffIds: updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AZIONI REMINDER ---
|
||||||
|
void addReminderRule(int minutesBefore, String channel) {
|
||||||
|
final updated = List<TaskReminderConfig>.from(state.reminders);
|
||||||
|
final newConfig = TaskReminderConfig(
|
||||||
|
minutesBefore: minutesBefore,
|
||||||
|
channel: channel,
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
createdBy: _currentUser,
|
||||||
|
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: _currentUser.id!,
|
||||||
|
currentUserCustomReminders: state.reminders,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _repository.updateTask(
|
||||||
|
task: taskToSave,
|
||||||
|
assignedStaffIds: state.selectedStaffIds,
|
||||||
|
currentUserId: _currentUser.id!,
|
||||||
|
currentUserCustomReminders: state.reminders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
emit(state.copyWith(status: TaskFormStatus.success));
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: TaskFormStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteTask() async {
|
||||||
|
if (state.id == null) return;
|
||||||
|
emit(state.copyWith(status: TaskFormStatus.submitting));
|
||||||
|
try {
|
||||||
|
await _repository.deleteTask(state.id!);
|
||||||
|
emit(state.copyWith(status: TaskFormStatus.success));
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: TaskFormStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateTaskLocalStatus(TaskStatus newStatus) async {
|
||||||
|
emit(state.copyWith(taskStatus: newStatus));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateTaskStatus(TaskStatus newStatus) async {
|
||||||
|
try {
|
||||||
|
// Chiamiamo il repo passando il task aggiornato
|
||||||
|
await _repository.updateTaskStatus(
|
||||||
|
taskId: state.id!,
|
||||||
|
newStatus: newStatus,
|
||||||
|
updatedById: _currentUser.id!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isClosed) {
|
||||||
|
// Se l'update va a buon fine, aggiorniamo lo stato locale del cubit
|
||||||
|
emit(
|
||||||
|
state.copyWith(status: TaskFormStatus.success, taskStatus: newStatus),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: TaskFormStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
lib/features/tasks/blocs/task_form_state.dart
Normal file
77
lib/features/tasks/blocs/task_form_state.dart
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
part of 'task_form_cubit.dart';
|
||||||
|
|
||||||
|
enum TaskFormStatus { initial, loading, submitting, success, failure }
|
||||||
|
|
||||||
|
class TaskFormState extends Equatable {
|
||||||
|
final String? id;
|
||||||
|
final TaskFormStatus status;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final DateTime? dueDate;
|
||||||
|
final bool isGlobal;
|
||||||
|
final List<String> selectedStaffIds;
|
||||||
|
final List<TaskReminderConfig> reminders;
|
||||||
|
final Map<String, List<StaffMemberModel>> groupedAvailableStaff;
|
||||||
|
final String? errorMessage;
|
||||||
|
final TaskStatus taskStatus;
|
||||||
|
|
||||||
|
const TaskFormState({
|
||||||
|
this.id,
|
||||||
|
this.status = TaskFormStatus.initial,
|
||||||
|
this.title = '',
|
||||||
|
this.description = '',
|
||||||
|
this.dueDate,
|
||||||
|
this.isGlobal = false,
|
||||||
|
this.selectedStaffIds = const [],
|
||||||
|
this.reminders = const [],
|
||||||
|
this.groupedAvailableStaff = const {},
|
||||||
|
this.errorMessage,
|
||||||
|
this.taskStatus = TaskStatus.open,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isFormValid => title.trim().isNotEmpty;
|
||||||
|
|
||||||
|
TaskFormState copyWith({
|
||||||
|
String? id,
|
||||||
|
TaskFormStatus? status,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
DateTime? dueDate,
|
||||||
|
bool? isGlobal,
|
||||||
|
List<String>? selectedStaffIds,
|
||||||
|
List<TaskReminderConfig>? reminders,
|
||||||
|
Map<String, List<StaffMemberModel>>? groupedAvailableStaff,
|
||||||
|
String? errorMessage,
|
||||||
|
TaskStatus? taskStatus,
|
||||||
|
}) {
|
||||||
|
return TaskFormState(
|
||||||
|
id: id ?? this.id,
|
||||||
|
status: status ?? this.status,
|
||||||
|
title: title ?? this.title,
|
||||||
|
description: description ?? this.description,
|
||||||
|
dueDate: dueDate ?? this.dueDate,
|
||||||
|
isGlobal: isGlobal ?? this.isGlobal,
|
||||||
|
selectedStaffIds: selectedStaffIds ?? this.selectedStaffIds,
|
||||||
|
reminders: reminders ?? this.reminders,
|
||||||
|
groupedAvailableStaff:
|
||||||
|
groupedAvailableStaff ?? this.groupedAvailableStaff,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
taskStatus: taskStatus ?? this.taskStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
dueDate,
|
||||||
|
isGlobal,
|
||||||
|
selectedStaffIds,
|
||||||
|
reminders,
|
||||||
|
groupedAvailableStaff,
|
||||||
|
errorMessage,
|
||||||
|
taskStatus,
|
||||||
|
];
|
||||||
|
}
|
||||||
73
lib/features/tasks/blocs/task_list_cubit.dart
Normal file
73
lib/features/tasks/blocs/task_list_cubit.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/features/tasks/data/task_repository.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_model.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
|
part 'task_list_state.dart';
|
||||||
|
|
||||||
|
class TaskListCubit extends Cubit<TaskListState> {
|
||||||
|
final TasksRepository _repository = GetIt.I.get<TasksRepository>();
|
||||||
|
final String currentCompanyId;
|
||||||
|
final String? currentStoreId;
|
||||||
|
|
||||||
|
// Il nostro abbonamento allo stream del repository
|
||||||
|
StreamSubscription<void>? _taskSubscription;
|
||||||
|
|
||||||
|
TaskListCubit({required this.currentCompanyId, this.currentStoreId})
|
||||||
|
: super(const TaskListState()) {
|
||||||
|
_initRealtime();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initRealtime() {
|
||||||
|
emit(state.copyWith(status: TaskListStatus.loading));
|
||||||
|
|
||||||
|
// Primo caricamento
|
||||||
|
_loadTasksSilently();
|
||||||
|
|
||||||
|
// Ci mettiamo in ascolto del campanello del Repository
|
||||||
|
_taskSubscription = _repository.watchCompanyTasks(currentCompanyId).listen((
|
||||||
|
_,
|
||||||
|
) {
|
||||||
|
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
|
||||||
|
_loadTasksSilently();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadTasks() async {
|
||||||
|
emit(state.copyWith(status: TaskListStatus.loading));
|
||||||
|
await _loadTasksSilently();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTasksSilently() async {
|
||||||
|
try {
|
||||||
|
final tasks = await _repository.getTasks(
|
||||||
|
companyId: currentCompanyId,
|
||||||
|
storeId: currentStoreId,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: TaskListStatus.success,
|
||||||
|
tasks: tasks,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: TaskListStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
// Stacchiamo l'abbonamento. Il controller.onCancel nel Repo farà il resto!
|
||||||
|
_taskSubscription?.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
lib/features/tasks/blocs/task_list_state.dart
Normal file
30
lib/features/tasks/blocs/task_list_state.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
part of 'task_list_cubit.dart';
|
||||||
|
|
||||||
|
enum TaskListStatus { initial, loading, success, failure }
|
||||||
|
|
||||||
|
class TaskListState extends Equatable {
|
||||||
|
final TaskListStatus status;
|
||||||
|
final List<TaskModel> tasks;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const TaskListState({
|
||||||
|
this.status = TaskListStatus.initial,
|
||||||
|
this.tasks = const [],
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskListState copyWith({
|
||||||
|
TaskListStatus? status,
|
||||||
|
List<TaskModel>? tasks,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return TaskListState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
tasks: tasks ?? this.tasks,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [status, tasks, errorMessage];
|
||||||
|
}
|
||||||
445
lib/features/tasks/data/task_repository.dart
Normal file
445
lib/features/tasks/data/task_repository.dart
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
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 TasksRepository {
|
||||||
|
final _supabase = GetIt.I.get<SupabaseClient>();
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// LETTURA REMINDER (Per il form in edit)
|
||||||
|
// =========================================================================
|
||||||
|
Future<List<TaskReminderConfig>> fetchPersonalReminders({
|
||||||
|
required String taskId,
|
||||||
|
required String staffId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _supabase
|
||||||
|
.from(Tables.taskReminders)
|
||||||
|
.select()
|
||||||
|
.eq('task_id', taskId)
|
||||||
|
.eq('staff_id', staffId)
|
||||||
|
.eq(
|
||||||
|
'is_forced',
|
||||||
|
false,
|
||||||
|
); // Peschiamo SOLO quelli modificabili dall'utente
|
||||||
|
|
||||||
|
return (response as List)
|
||||||
|
.map(
|
||||||
|
(r) => TaskReminderConfig(
|
||||||
|
minutesBefore: r['minutes_before'],
|
||||||
|
channel: r['channel'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Errore fetch personal reminders: $e');
|
||||||
|
throw Exception('Errore fetch personal reminders: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RECUPERO DEI TASK FILTRATI ---
|
||||||
|
Future<List<TaskModel>> getTasks({
|
||||||
|
required String companyId,
|
||||||
|
String? storeId,
|
||||||
|
String? staffId,
|
||||||
|
List<TaskStatus>? statuses,
|
||||||
|
int? limit,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// 1. FASE FILTRI: Disambiguazione completa su Tasks e Assignments
|
||||||
|
var filterBuilder = _supabase
|
||||||
|
.from(Tables.tasks)
|
||||||
|
.select('''
|
||||||
|
*,
|
||||||
|
creator:${Tables.staffMembers}!created_by_id(*),
|
||||||
|
updater:${Tables.staffMembers}!updated_by_id(*),
|
||||||
|
task_assignments:${Tables.taskAssignments} (
|
||||||
|
*,
|
||||||
|
assignee:${Tables.staffMembers}!staff_id(*),
|
||||||
|
assigner:${Tables.staffMembers}!assigned_by_id(*)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
.eq('company_id', companyId);
|
||||||
|
|
||||||
|
if (storeId != null) {
|
||||||
|
filterBuilder = filterBuilder.or(
|
||||||
|
'store_id.eq.$storeId,store_id.is.null',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staffId != null) {
|
||||||
|
filterBuilder = filterBuilder.contains('assigned_to_ids', [staffId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statuses != null && statuses.isNotEmpty) {
|
||||||
|
final statusValues = statuses.map((s) => s.toValue).toList();
|
||||||
|
filterBuilder = filterBuilder.inFilter('status', statusValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. FASE TRASFORMAZIONI
|
||||||
|
var transformBuilder = filterBuilder
|
||||||
|
.order('due_date', ascending: true, nullsFirst: false)
|
||||||
|
.order('created_at', ascending: false, nullsFirst: false);
|
||||||
|
|
||||||
|
if (limit != null) {
|
||||||
|
transformBuilder = transformBuilder.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ESECUZIONE DELLA QUERY
|
||||||
|
final response = await transformBuilder;
|
||||||
|
|
||||||
|
// 4. PARSING DEI DATI
|
||||||
|
return (response as List).map((json) => TaskModel.fromMap(json)).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Errore nel recupero dei task: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TaskModel?> fetchTaskById(String taskId) async {
|
||||||
|
try {
|
||||||
|
final response = await _supabase
|
||||||
|
.from(Tables.tasks)
|
||||||
|
.select('''
|
||||||
|
*,
|
||||||
|
creator:${Tables.staffMembers}!created_by_id(*),
|
||||||
|
updater:${Tables.staffMembers}!updated_by_id(*),
|
||||||
|
task_assignments:${Tables.taskAssignments} (
|
||||||
|
*,
|
||||||
|
assignee:${Tables.staffMembers}!staff_id(*),
|
||||||
|
assigner:${Tables.staffMembers}!assigned_by_id(*)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
.eq('id', taskId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
return TaskModel.fromMap(response);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Errore fetch task by id: $e');
|
||||||
|
throw Exception('Errore fetch task by id: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// REALTIME STREAM (La sentinella per la bacheca)
|
||||||
|
// =========================================================================
|
||||||
|
Stream<List<Map<String, dynamic>>> watchCompanyTasks(String companyId) {
|
||||||
|
return _supabase
|
||||||
|
.from('tasks')
|
||||||
|
.stream(primaryKey: ['id'])
|
||||||
|
.eq('company_id', companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
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,
|
||||||
|
'assigned_by_id': currentUserId,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
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(Tables.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. GESTIONE CHIRURGICA DELLE ASSEGNAZIONI (Addio spam!)
|
||||||
|
|
||||||
|
// A) Recuperiamo chi è GIÀ assegnato a questo task
|
||||||
|
final existingAssignmentsResponse = await _supabase
|
||||||
|
.from('task_assignments')
|
||||||
|
.select('staff_id')
|
||||||
|
.eq('task_id', taskId);
|
||||||
|
|
||||||
|
final List<String> existingStaffIds =
|
||||||
|
(existingAssignmentsResponse as List)
|
||||||
|
.map((row) => row['staff_id'] as String)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// B) Calcoliamo i Delta con i Set di Dart (Pura magia matematica)
|
||||||
|
final newStaffIdsSet = assignedStaffIds.toSet();
|
||||||
|
final existingStaffIdsSet = existingStaffIds.toSet();
|
||||||
|
|
||||||
|
// Quelli da inserire (presenti nei nuovi, ma non nei vecchi)
|
||||||
|
final toInsertIds = newStaffIdsSet
|
||||||
|
.difference(existingStaffIdsSet)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Quelli da eliminare (presenti nei vecchi, ma non nei nuovi)
|
||||||
|
final toDeleteIds = existingStaffIdsSet
|
||||||
|
.difference(newStaffIdsSet)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// C) Eseguiamo solo lo stretto necessario
|
||||||
|
if (toDeleteIds.isNotEmpty) {
|
||||||
|
await _supabase
|
||||||
|
.from('task_assignments')
|
||||||
|
.delete()
|
||||||
|
.eq('task_id', taskId)
|
||||||
|
.inFilter('staff_id', toDeleteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toInsertIds.isNotEmpty) {
|
||||||
|
final assignmentsToInsert = toInsertIds
|
||||||
|
.map(
|
||||||
|
(staffId) => {
|
||||||
|
'task_id': taskId,
|
||||||
|
'staff_id': staffId,
|
||||||
|
'company_id': task.companyId,
|
||||||
|
'assigned_by_id':
|
||||||
|
currentUserId, // Il nostro salvavita anti-fantasma
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateTaskStatus({
|
||||||
|
required String taskId,
|
||||||
|
required TaskStatus newStatus,
|
||||||
|
required String? updatedById,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _supabase
|
||||||
|
.from(Tables.tasks)
|
||||||
|
.update({
|
||||||
|
'status': newStatus.toValue,
|
||||||
|
'updated_by_id': updatedById,
|
||||||
|
'updated_at': DateTime.now().toIso8601String(),
|
||||||
|
})
|
||||||
|
.eq('id', taskId);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception(
|
||||||
|
'Errore durante l\'aggiornamento dello stato del task: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteTask(String taskId) async {
|
||||||
|
try {
|
||||||
|
await _supabase.from(Tables.tasks).delete().eq('id', taskId);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Errore durante la cancellazione 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
65
lib/features/tasks/models/reminder_default_model.dart
Normal file
65
lib/features/tasks/models/reminder_default_model.dart
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
166
lib/features/tasks/models/task_model.dart
Normal file
166
lib/features/tasks/models/task_model.dart
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_status.dart';
|
||||||
|
|
||||||
|
class TaskModel extends Equatable {
|
||||||
|
final String? id;
|
||||||
|
final String? companyId;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final List<String> assignedToIds;
|
||||||
|
final List<StaffMemberModel> assignedToStaff; // I dati completi dal JOIN
|
||||||
|
final StaffMemberModel? createdBy;
|
||||||
|
final DateTime? dueDate;
|
||||||
|
final TaskStatus status;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final String? storeId;
|
||||||
|
final StaffMemberModel? updatedBy;
|
||||||
|
|
||||||
|
const TaskModel({
|
||||||
|
this.id,
|
||||||
|
this.companyId,
|
||||||
|
required this.title,
|
||||||
|
this.description,
|
||||||
|
this.assignedToIds = const [],
|
||||||
|
this.assignedToStaff = const [],
|
||||||
|
this.createdBy,
|
||||||
|
this.dueDate,
|
||||||
|
this.status = TaskStatus.open,
|
||||||
|
this.createdAt,
|
||||||
|
this.storeId,
|
||||||
|
this.updatedBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isGlobal => storeId == null;
|
||||||
|
|
||||||
|
// --- FACTORY: MODELLO VUOTO (Per le creazioni) ---
|
||||||
|
factory TaskModel.empty({String? companyId, StaffMemberModel? createdBy}) {
|
||||||
|
return TaskModel(
|
||||||
|
companyId: companyId,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
assignedToIds: const [],
|
||||||
|
assignedToStaff: const [],
|
||||||
|
createdBy: createdBy,
|
||||||
|
status: TaskStatus.open,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- EQUATABLE: PROPRIETÀ DA COMPARARE ---
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
id,
|
||||||
|
companyId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
assignedToIds,
|
||||||
|
assignedToStaff,
|
||||||
|
createdBy,
|
||||||
|
dueDate,
|
||||||
|
status,
|
||||||
|
createdAt,
|
||||||
|
storeId,
|
||||||
|
updatedBy,
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- COPY WITH ---
|
||||||
|
TaskModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? companyId,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
List<String>? assignedToIds,
|
||||||
|
List<StaffMemberModel>? assignedToStaff,
|
||||||
|
StaffMemberModel? createdBy,
|
||||||
|
DateTime? dueDate,
|
||||||
|
bool clearDueDate = false, // Flag ninja per resettare la scadenza
|
||||||
|
TaskStatus? status,
|
||||||
|
DateTime? createdAt,
|
||||||
|
String? storeId,
|
||||||
|
bool clearStoreId = false,
|
||||||
|
StaffMemberModel? updatedBy,
|
||||||
|
String? updatedByDisplayName,
|
||||||
|
}) {
|
||||||
|
return TaskModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
companyId: companyId ?? this.companyId,
|
||||||
|
title: title ?? this.title,
|
||||||
|
description: description ?? this.description,
|
||||||
|
assignedToIds: assignedToIds ?? this.assignedToIds,
|
||||||
|
assignedToStaff: assignedToStaff ?? this.assignedToStaff,
|
||||||
|
createdBy: createdBy ?? this.createdBy,
|
||||||
|
dueDate: clearDueDate ? null : (dueDate ?? this.dueDate),
|
||||||
|
status: status ?? this.status,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
storeId: clearStoreId ? null : (storeId ?? this.storeId),
|
||||||
|
updatedBy: updatedBy ?? this.updatedBy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SERIALIZZAZIONE DA SUPABASE ---
|
||||||
|
factory TaskModel.fromMap(Map<String, dynamic> map) {
|
||||||
|
// 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota
|
||||||
|
final List<String> parsedAssignedToIds = map['assigned_to_ids'] != null
|
||||||
|
? List<String>.from(map['assigned_to_ids'])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 2. Mappiamo il JOIN dello staff, se presente
|
||||||
|
List<StaffMemberModel> staffList = [];
|
||||||
|
|
||||||
|
// Gestione del JSON proveniente dal Join nidificato (es. task_assignments -> staff_members)
|
||||||
|
if (map['task_assignments'] != null) {
|
||||||
|
staffList = (map['task_assignments'] as List)
|
||||||
|
.map((a) => a['staff_members'])
|
||||||
|
.where((s) => s != null)
|
||||||
|
.map((s) => StaffMemberModel.fromMap(s))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
// Gestione del JSON piatto (se mai lo userai in altre chiamate RPC o viste)
|
||||||
|
else if (map['assigned_to_staff'] != null) {
|
||||||
|
staffList = (map['assigned_to_staff'] as List)
|
||||||
|
.map((s) => StaffMemberModel.fromMap(s))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskModel(
|
||||||
|
id: map['id'] as String?,
|
||||||
|
companyId: map['company_id'] as String?,
|
||||||
|
title: map['title'] as String? ?? '',
|
||||||
|
description: map['description'] as String?,
|
||||||
|
assignedToIds: parsedAssignedToIds,
|
||||||
|
assignedToStaff: staffList,
|
||||||
|
createdBy: map['created_by_id'] != null
|
||||||
|
? StaffMemberModel.fromMap(map['creator'])
|
||||||
|
: null,
|
||||||
|
dueDate: map['due_date'] != null
|
||||||
|
? DateTime.parse(map['due_date'] as String).toLocal()
|
||||||
|
: null,
|
||||||
|
status: TaskStatusExtension.fromString(map['status'] as String?),
|
||||||
|
createdAt: map['created_at'] != null
|
||||||
|
? DateTime.parse(map['created_at'] as String).toLocal()
|
||||||
|
: null,
|
||||||
|
storeId: map['store_id'] as String?,
|
||||||
|
updatedBy: map['updated_by_id'] != null
|
||||||
|
? StaffMemberModel.fromMap(map['updater'])
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SERIALIZZAZIONE VERSO SUPABASE ---
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
if (companyId != null) 'company_id': companyId,
|
||||||
|
'title': title,
|
||||||
|
if (description != null) 'description': description,
|
||||||
|
// Passiamo l'array vuoto se non ci sono assegnazioni
|
||||||
|
'assigned_to_ids': assignedToIds.isEmpty ? null : assignedToIds,
|
||||||
|
if (createdBy != null) 'created_by_id': createdBy!.id,
|
||||||
|
'due_date': dueDate?.toUtc().toIso8601String(),
|
||||||
|
'status': status.toValue,
|
||||||
|
'store_id': storeId,
|
||||||
|
if (updatedBy != null) 'updated_by_id': updatedBy!.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
22
lib/features/tasks/models/task_reminder_config.dart
Normal file
22
lib/features/tasks/models/task_reminder_config.dart
Normal 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];
|
||||||
|
}
|
||||||
40
lib/features/tasks/models/task_status.dart
Normal file
40
lib/features/tasks/models/task_status.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Enum per lo stato del task
|
||||||
|
enum TaskStatus { open, inProgress, completed }
|
||||||
|
|
||||||
|
extension TaskStatusExtension on TaskStatus {
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case TaskStatus.open:
|
||||||
|
return 'Da Iniziare';
|
||||||
|
case TaskStatus.inProgress:
|
||||||
|
return 'In Lavorazione';
|
||||||
|
case TaskStatus.completed:
|
||||||
|
return 'Completato';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comodo per mappare da Supabase
|
||||||
|
static TaskStatus fromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'in_progress':
|
||||||
|
return TaskStatus.inProgress;
|
||||||
|
case 'completed':
|
||||||
|
return TaskStatus.completed;
|
||||||
|
case 'open':
|
||||||
|
default:
|
||||||
|
return TaskStatus.open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comodo per salvare su Supabase
|
||||||
|
String get toValue {
|
||||||
|
switch (this) {
|
||||||
|
case TaskStatus.open:
|
||||||
|
return 'open';
|
||||||
|
case TaskStatus.inProgress:
|
||||||
|
return 'in_progress';
|
||||||
|
case TaskStatus.completed:
|
||||||
|
return 'completed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
652
lib/features/tasks/ui/task_form_screen.dart
Normal file
652
lib/features/tasks/ui/task_form_screen.dart
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/features/tasks/blocs/task_form_cubit.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_status.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
class TaskFormScreen extends StatefulWidget {
|
||||||
|
const TaskFormScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TaskFormScreen> createState() => _TaskFormScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TaskFormScreenState extends State<TaskFormScreen> {
|
||||||
|
late final TextEditingController _titleController;
|
||||||
|
late final TextEditingController _descController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Leggiamo lo stato iniziale dal Cubit (che ha già i dati del task esistente)
|
||||||
|
final initialState = context.read<TaskFormCubit>().state;
|
||||||
|
|
||||||
|
_titleController = TextEditingController(text: initialState.title);
|
||||||
|
_descController = TextEditingController(text: initialState.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_descController.dispose();
|
||||||
|
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>(
|
||||||
|
listenWhen: (previous, current) => previous.status != current.status,
|
||||||
|
listener: (context, state) {
|
||||||
|
// GESTIONE DEEP LINK: Se eravamo in caricamento e ora siamo pronti, popoliamo i controller!
|
||||||
|
if (state.status == TaskFormStatus.initial) {
|
||||||
|
if (_titleController.text != state.title) {
|
||||||
|
_titleController.text = state.title;
|
||||||
|
}
|
||||||
|
if (_descController.text != state.description) {
|
||||||
|
_descController.text = state.description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.status == TaskFormStatus.success) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Task salvato con successo! 🎉')),
|
||||||
|
);
|
||||||
|
context.pop();
|
||||||
|
} else if (state.status == TaskFormStatus.failure) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
final cubit = context.read<TaskFormCubit>();
|
||||||
|
final isEditing = state.id != null;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(isEditing ? 'Modifica Task' : 'Nuovo Task'),
|
||||||
|
actions: [
|
||||||
|
// 🥷 1. BOTTONE COMPLETAMENTO RAPIDO (Solo se in edit e non già completato)
|
||||||
|
if (isEditing && state.taskStatus != TaskStatus.completed)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8.0,
|
||||||
|
vertical: 8.0,
|
||||||
|
),
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
context.read<TaskFormCubit>().updateTaskStatus(
|
||||||
|
TaskStatus.completed,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.check_circle_outline),
|
||||||
|
label: const Text('Completa'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green.shade600,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 🥷 2. LOADER O BOTTONE SALVA
|
||||||
|
if (state.status == TaskFormStatus.submitting)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: state.isFormValid
|
||||||
|
? () => cubit.saveTask()
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
label: const Text('Salva'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.orange,
|
||||||
|
disabledForegroundColor: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: state.status == TaskFormStatus.loading
|
||||||
|
// Se sta scaricando i dati dal Deep Link, mostriamo un bel loader centrato
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final isWideScreen = constraints.maxWidth > 800;
|
||||||
|
|
||||||
|
if (isWideScreen) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 6,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: _buildFormFields(context, state, cubit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
VerticalDivider(
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: _buildStaffSelectorInline(
|
||||||
|
context,
|
||||||
|
state,
|
||||||
|
cubit,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildFormFields(context, state, cubit),
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
_showStaffBottomSheet(context, cubit),
|
||||||
|
icon: const Icon(Icons.group_add),
|
||||||
|
label: Text(
|
||||||
|
state.selectedStaffIds.isEmpty
|
||||||
|
? 'Assegna Staff'
|
||||||
|
: 'Assegnato a ${state.selectedStaffIds.length} persone',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- I CAMPI DEL FORM (Aggiornati con i Controller) ---
|
||||||
|
Widget _buildFormFields(
|
||||||
|
BuildContext context,
|
||||||
|
TaskFormState state,
|
||||||
|
TaskFormCubit cubit,
|
||||||
|
) {
|
||||||
|
return FocusTraversalGroup(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(
|
||||||
|
color: Theme.of(context).dividerColor.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SwitchListTile(
|
||||||
|
title: const Text(
|
||||||
|
'Task Globale Aziendale',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Visibile a tutta l\'azienda, non legato a un negozio specifico.',
|
||||||
|
),
|
||||||
|
value: state.isGlobal,
|
||||||
|
activeThumbColor: Colors.orange,
|
||||||
|
onChanged: (val) => cubit.toggleGlobalScope(val),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Addio initialValue, benvenuto controller!
|
||||||
|
TextFormField(
|
||||||
|
controller: _titleController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Titolo del Task*',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.title),
|
||||||
|
),
|
||||||
|
onChanged: cubit.updateTitle,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descController,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Descrizione (opzionale)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
onChanged: cubit.updateDescription,
|
||||||
|
),
|
||||||
|
if (state.id != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<TaskStatus>(
|
||||||
|
// Leggiamo lo stato attuale dal Cubit (o usiamo un default per i nuovi task)
|
||||||
|
initialValue: state.taskStatus,
|
||||||
|
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Stato Attuale',
|
||||||
|
prefixIcon: const Icon(Icons.flag_outlined),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Theme.of(context).colorScheme.surface,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Mappiamo tutti i valori dell'enum in elementi della tendina
|
||||||
|
items: TaskStatus.values.map((TaskStatus status) {
|
||||||
|
return DropdownMenuItem<TaskStatus>(
|
||||||
|
value: status,
|
||||||
|
child: Text(
|
||||||
|
status
|
||||||
|
.displayName, // Usa la property displayName del tuo enum!
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: status == state.taskStatus
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
|
||||||
|
onChanged: (TaskStatus? newStatus) {
|
||||||
|
if (newStatus != null && newStatus != state.taskStatus) {
|
||||||
|
context.read<TaskFormCubit>().updateTaskStatus(newStatus);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// --- SCADENZA ---
|
||||||
|
ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
side: BorderSide(color: Theme.of(context).dividerColor),
|
||||||
|
),
|
||||||
|
leading: const Icon(Icons.calendar_today, color: Colors.orange),
|
||||||
|
title: Text(
|
||||||
|
state.dueDate != null
|
||||||
|
// 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
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => cubit.updateDueDate(null),
|
||||||
|
)
|
||||||
|
: 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),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. SELEZIONE STAFF INLINE (PER DESKTOP/WIDE)
|
||||||
|
// =========================================================================
|
||||||
|
Widget _buildStaffSelectorInline(
|
||||||
|
BuildContext context,
|
||||||
|
TaskFormState state,
|
||||||
|
TaskFormCubit cubit,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(24.0),
|
||||||
|
child: Text(
|
||||||
|
'Assegnazione Staff',
|
||||||
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
children: _buildGroupedStaffList(context, state, cubit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. BOTTOM SHEET SELEZIONE STAFF (PER MOBILE)
|
||||||
|
// =========================================================================
|
||||||
|
void _showStaffBottomSheet(BuildContext context, TaskFormCubit cubit) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (bottomSheetContext) {
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
expand: false,
|
||||||
|
initialChildSize: 0.7, // Occupa il 70% dello schermo in altezza
|
||||||
|
minChildSize: 0.5,
|
||||||
|
maxChildSize: 0.9,
|
||||||
|
builder: (_, controller) {
|
||||||
|
return BlocBuilder<TaskFormCubit, TaskFormState>(
|
||||||
|
bloc:
|
||||||
|
cubit, // Passiamo il cubit esistente per mantenere lo stato!
|
||||||
|
builder: (context, state) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Text(
|
||||||
|
'Assegna Staff',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
controller: controller,
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
children: _buildGroupedStaffList(context, state, cubit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 4. GENERATORE DELLA LISTA RAGGRUPPATA (RIUTILIZZABILE)
|
||||||
|
// =========================================================================
|
||||||
|
List<Widget> _buildGroupedStaffList(
|
||||||
|
BuildContext context,
|
||||||
|
TaskFormState state,
|
||||||
|
TaskFormCubit cubit,
|
||||||
|
) {
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
|
||||||
|
if (state.groupedAvailableStaff.isEmpty) {
|
||||||
|
return [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(32.0),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Nessun membro dello staff trovato.',
|
||||||
|
style: TextStyle(fontStyle: FontStyle.italic),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iteriamo sulla mappa { "Nome Negozio" : [Lista Dipendenti] }
|
||||||
|
for (final entry in state.groupedAvailableStaff.entries) {
|
||||||
|
final storeName = entry.key;
|
||||||
|
final staffList = entry.value;
|
||||||
|
|
||||||
|
// Verifichiamo se TUTTI i membri di questo negozio sono selezionati
|
||||||
|
final allSelectedInStore = staffList.every(
|
||||||
|
(staff) => state.selectedStaffIds.contains(staff.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
widgets.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 24.0,
|
||||||
|
bottom: 8.0,
|
||||||
|
left: 16,
|
||||||
|
right: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
storeName.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// IL MAGICO BOTTONE "SELEZIONA TUTTI" DEL NEGOZIO
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
cubit.toggleStoreSelection(storeName, !allSelectedInStore),
|
||||||
|
icon: Icon(
|
||||||
|
allSelectedInStore ? Icons.deselect : Icons.select_all,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
allSelectedInStore ? 'Deseleziona' : 'Seleziona Tutti',
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
foregroundColor: allSelectedInStore
|
||||||
|
? Colors.grey
|
||||||
|
: Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Renderizziamo i dipendenti di questo negozio usando dei Wrap con FilterChip
|
||||||
|
widgets.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8.0,
|
||||||
|
runSpacing: 8.0,
|
||||||
|
children: staffList.map((staff) {
|
||||||
|
final isSelected = state.selectedStaffIds.contains(staff.id);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(staff.name),
|
||||||
|
selected: isSelected,
|
||||||
|
selectedColor: Colors.orange.withValues(alpha: 0.2),
|
||||||
|
checkmarkColor: Colors.orange,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
onSelected: (_) => cubit.toggleStaffSelection(staff.id!),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
}
|
||||||
272
lib/features/tasks/ui/task_list_screen.dart
Normal file
272
lib/features/tasks/ui/task_list_screen.dart
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/features/tasks/blocs/task_list_cubit.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:flux/core/routes/routes.dart';
|
||||||
|
import 'package:flux/features/tasks/models/task_status.dart'; // Adegua al tuo path
|
||||||
|
import 'package:flux/features/tasks/models/task_model.dart';
|
||||||
|
|
||||||
|
class TaskListScreen extends StatelessWidget {
|
||||||
|
const TaskListScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Usiamo 3 tab per gli stati principali
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 3,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Gestione Task'),
|
||||||
|
bottom: const TabBar(
|
||||||
|
indicatorColor: Colors.orange,
|
||||||
|
labelColor: Colors.orange,
|
||||||
|
tabs: [
|
||||||
|
Tab(text: 'Da Fare'),
|
||||||
|
Tab(text: 'In Corso'),
|
||||||
|
Tab(text: 'Completati'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: () => context.read<TaskListCubit>().loadTasks(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: BlocBuilder<TaskListCubit, TaskListState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.status == TaskListStatus.loading ||
|
||||||
|
state.status == TaskListStatus.initial) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status == TaskListStatus.failure) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(state.errorMessage ?? 'Errore sconosciuto'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () =>
|
||||||
|
context.read<TaskListCubit>().loadTasks(),
|
||||||
|
child: const Text('Riprova'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtriamo le 3 liste in memoria per ogni Tab
|
||||||
|
final todoTasks = state.tasks
|
||||||
|
.where((t) => t.status == TaskStatus.open)
|
||||||
|
.toList();
|
||||||
|
final inProgressTasks = state.tasks
|
||||||
|
.where((t) => t.status == TaskStatus.inProgress)
|
||||||
|
.toList();
|
||||||
|
final doneTasks = state.tasks
|
||||||
|
.where((t) => t.status == TaskStatus.completed)
|
||||||
|
.toList(); // Adegua in base ai tuoi enum
|
||||||
|
|
||||||
|
return TabBarView(
|
||||||
|
children: [
|
||||||
|
_buildTaskList(context, todoTasks, 'Nessun task da fare. 🎉'),
|
||||||
|
_buildTaskList(
|
||||||
|
context,
|
||||||
|
inProgressTasks,
|
||||||
|
'Nessun task in lavorazione.',
|
||||||
|
),
|
||||||
|
_buildTaskList(context, doneTasks, 'Nessun task completato.'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
onPressed: () =>
|
||||||
|
context.pushNamed(Routes.taskForm, pathParameters: {'id': 'new'}),
|
||||||
|
icon: const Icon(Icons.add, color: Colors.white),
|
||||||
|
label: const Text(
|
||||||
|
'Nuovo Task',
|
||||||
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WIDGET LISTA ---
|
||||||
|
Widget _buildTaskList(
|
||||||
|
BuildContext context,
|
||||||
|
List<TaskModel> tasks,
|
||||||
|
String emptyMessage,
|
||||||
|
) {
|
||||||
|
if (tasks.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
emptyMessage,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).textTheme.bodySmall?.color,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => context.read<TaskListCubit>().loadTasks(),
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 16,
|
||||||
|
bottom: 80,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
), // Padding bottom per il FAB
|
||||||
|
itemCount: tasks.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final task = tasks[index];
|
||||||
|
final isOverdue =
|
||||||
|
task.dueDate != null && task.dueDate!.isBefore(DateTime.now());
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(
|
||||||
|
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () => context.pushNamed(
|
||||||
|
Routes.taskForm,
|
||||||
|
pathParameters: {'id': task.id!},
|
||||||
|
extra: task,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Riga 1: Badge Globale/Store + Data Scadenza
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: task.storeId == null
|
||||||
|
? Colors.purple.withValues(alpha: 0.1)
|
||||||
|
: Colors.blue.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
task.storeId == null ? 'GLOBALE' : 'STORE',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: task.storeId == null
|
||||||
|
? Colors.purple
|
||||||
|
: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (task.dueDate != null)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 14,
|
||||||
|
color: isOverdue ? Colors.red : Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${task.dueDate!.day}/${task.dueDate!.month}/${task.dueDate!.year}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isOverdue
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isOverdue ? Colors.red : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Riga 2: Titolo
|
||||||
|
Text(
|
||||||
|
task.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Riga 3 (Opzionale): Descrizione breve
|
||||||
|
if (task.description != null &&
|
||||||
|
task.description!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
task.description!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Riga 4: Assegnatari
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.people_outline,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
(task.assignedToStaff.isEmpty)
|
||||||
|
? 'Nessun assegnatario'
|
||||||
|
: task.assignedToStaff
|
||||||
|
.map((s) => s.name)
|
||||||
|
.join(', '),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,8 @@ class TicketFormCubit extends Cubit<TicketFormState> {
|
|||||||
String? assignedToId,
|
String? assignedToId,
|
||||||
String? assignedToName,
|
String? assignedToName,
|
||||||
WarrantyType? warrantyType,
|
WarrantyType? warrantyType,
|
||||||
|
DateTime? estimatedDeliveryAt,
|
||||||
|
bool clearEstimatedDelivery = false,
|
||||||
}) {
|
}) {
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -162,6 +164,8 @@ class TicketFormCubit extends Cubit<TicketFormState> {
|
|||||||
assignedToId: assignedToId ?? state.ticket.assignedToId,
|
assignedToId: assignedToId ?? state.ticket.assignedToId,
|
||||||
assignedToName: assignedToName ?? state.ticket.assignedToName,
|
assignedToName: assignedToName ?? state.ticket.assignedToName,
|
||||||
warrantyType: warrantyType ?? state.ticket.warrantyType,
|
warrantyType: warrantyType ?? state.ticket.warrantyType,
|
||||||
|
estimatedDeliveryAt: estimatedDeliveryAt,
|
||||||
|
clearEstimatedDelivery: clearEstimatedDelivery,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -363,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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import 'package:equatable/equatable.dart';
|
|||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||||
// Adatta gli import al tuo progetto!
|
// 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 {
|
class TicketFormState extends Equatable {
|
||||||
final TicketModel ticket;
|
final TicketModel ticket;
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||||
import 'package:flux/features/tickets/data/ticket_repository.dart';
|
import 'package:flux/features/tickets/data/ticket_repository.dart';
|
||||||
|
import 'package:flux/features/tracking/data/tracking_repository.dart';
|
||||||
|
import 'package:flux/features/tracking/models/tracking_model.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'ticket_list_state.dart';
|
import 'ticket_list_state.dart';
|
||||||
|
|
||||||
class TicketListCubit extends Cubit<TicketListState> {
|
class TicketListCubit extends Cubit<TicketListState> {
|
||||||
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
|
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
|
||||||
|
final TrackingRepository _trackingRepository = GetIt.I
|
||||||
|
.get<TrackingRepository>();
|
||||||
static const int _limit = 20; // Paginazione a blocchi di 20
|
static const int _limit = 20; // Paginazione a blocchi di 20
|
||||||
|
|
||||||
TicketListCubit() : super(const TicketListState()) {
|
TicketListCubit() : super(const TicketListState()) {
|
||||||
@@ -95,4 +98,79 @@ class TicketListCubit extends Cubit<TicketListState> {
|
|||||||
void selectAll(List<TicketModel> tickets) {
|
void selectAll(List<TicketModel> tickets) {
|
||||||
emit(state.copyWith(selectedTickets: tickets.toSet()));
|
emit(state.copyWith(selectedTickets: tickets.toSet()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> closeTicketsBulk({
|
||||||
|
required List<String> ticketIds,
|
||||||
|
Map<String, bool>? loanReturns,
|
||||||
|
}) async {
|
||||||
|
// 1. Escludiamo i ticket per cui NON è stato restituito il muletto
|
||||||
|
if (loanReturns != null) {
|
||||||
|
for (final map in loanReturns.entries) {
|
||||||
|
if (!map.value) {
|
||||||
|
ticketIds.remove(map.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se non c'è più nulla da chiudere (es. ha rifiutato tutto), usciamo
|
||||||
|
if (ticketIds.isEmpty) {
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Prepariamo i ticket per il DB
|
||||||
|
final List<TicketModel> ticketsToUpdate = [];
|
||||||
|
for (final ticketId in ticketIds) {
|
||||||
|
final ticket = state.tickets
|
||||||
|
.firstWhere((ticket) => ticket.id == ticketId)
|
||||||
|
.copyWith(ticketStatus: TicketStatus.closed);
|
||||||
|
ticketsToUpdate.add(ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Salviamo su DB (in background)
|
||||||
|
for (final ticket in ticketsToUpdate) {
|
||||||
|
await _repository.updateTicket(ticket);
|
||||||
|
await _trackingRepository.logQuickEvent(
|
||||||
|
companyId: ticket.companyId,
|
||||||
|
message: 'Ticket chiuso - Riconsegnato',
|
||||||
|
type: TrackingType.statusChange,
|
||||||
|
parentId: ticket.id!,
|
||||||
|
parentType: TrackingParentType.ticket,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. LA MAGIA: AGGIORNAMENTO LOCALE ISTANTANEO
|
||||||
|
final updatedTickets = state.tickets.map((t) {
|
||||||
|
if (ticketIds.contains(t.id)) {
|
||||||
|
return t.copyWith(ticketStatus: TicketStatus.closed);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// 5. Emettiamo il nuovo stato aggiornato e puliamo la selezione in un colpo solo
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
tickets: updatedTickets,
|
||||||
|
selectedTickets: {}, // Equivalente di clearSelection()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Opzionale: Se vuoi comunque riallinearti al server in modo silenzioso dopo l'animazione
|
||||||
|
// loadTickets(refresh: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ class TicketRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- RECUPERO PAGINATO CON FILTRI E JOIN DEI TICKET DI TUTTA L'AZIENDA ---
|
// --- 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,
|
required int offset,
|
||||||
int limit = 50,
|
int limit = 50,
|
||||||
String? searchTerm,
|
String? searchTerm,
|
||||||
@@ -96,7 +98,7 @@ class TicketRepository {
|
|||||||
target_model:${Tables.models}!ticket_model_id_1_fkey (*),
|
target_model:${Tables.models}!ticket_model_id_1_fkey (*),
|
||||||
source_model:${Tables.models}!ticket_model_id_2_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
|
// Filtro Range Date
|
||||||
if (dateRange != null) {
|
if (dateRange != null) {
|
||||||
@@ -105,6 +107,10 @@ class TicketRepository {
|
|||||||
.lte('created_at', dateRange.end.toIso8601String());
|
.lte('created_at', dateRange.end.toIso8601String());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (storeId != null) {
|
||||||
|
query = query.or('store_id.eq.$storeId,store_id.is.null');
|
||||||
|
}
|
||||||
|
|
||||||
if (ticketStatusFilter != null) {
|
if (ticketStatusFilter != null) {
|
||||||
query = query.eq('status', ticketStatusFilter.value);
|
query = query.eq('status', ticketStatusFilter.value);
|
||||||
}
|
}
|
||||||
@@ -259,6 +265,18 @@ class TicketRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Chiude i ticket in bulk
|
||||||
|
Future<void> closeTickets(List<String> ticketIds) async {
|
||||||
|
try {
|
||||||
|
await _supabase
|
||||||
|
.from(_tableName)
|
||||||
|
.update({'ticket_status': TicketStatus.closed.value})
|
||||||
|
.inFilter('id', ticketIds);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Errore nella chiusura dei ticket: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Elimina (o annulla) un ticket
|
/// Elimina (o annulla) un ticket
|
||||||
Future<void> deleteTicket(String ticketId) async {
|
Future<void> deleteTicket(String ticketId) async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ class TicketModel extends Equatable {
|
|||||||
TicketType? ticketType,
|
TicketType? ticketType,
|
||||||
TicketStatus? ticketStatus,
|
TicketStatus? ticketStatus,
|
||||||
DateTime? estimatedDeliveryAt,
|
DateTime? estimatedDeliveryAt,
|
||||||
|
bool clearEstimatedDelivery = false,
|
||||||
TicketResult? ticketResult,
|
TicketResult? ticketResult,
|
||||||
String? resolutionNotes,
|
String? resolutionNotes,
|
||||||
String? shippingDocumentId,
|
String? shippingDocumentId,
|
||||||
@@ -242,7 +243,9 @@ class TicketModel extends Equatable {
|
|||||||
hasCourtesyDevice: hasCourtesyDevice ?? this.hasCourtesyDevice,
|
hasCourtesyDevice: hasCourtesyDevice ?? this.hasCourtesyDevice,
|
||||||
ticketType: ticketType ?? this.ticketType,
|
ticketType: ticketType ?? this.ticketType,
|
||||||
ticketStatus: ticketStatus ?? this.ticketStatus,
|
ticketStatus: ticketStatus ?? this.ticketStatus,
|
||||||
estimatedDeliveryAt: estimatedDeliveryAt ?? this.estimatedDeliveryAt,
|
estimatedDeliveryAt: clearEstimatedDelivery
|
||||||
|
? null
|
||||||
|
: (estimatedDeliveryAt ?? this.estimatedDeliveryAt),
|
||||||
ticketResult: ticketResult ?? this.ticketResult,
|
ticketResult: ticketResult ?? this.ticketResult,
|
||||||
resolutionNotes: resolutionNotes ?? this.resolutionNotes,
|
resolutionNotes: resolutionNotes ?? this.resolutionNotes,
|
||||||
shippingDocumentId: shippingDocumentId ?? this.shippingDocumentId,
|
shippingDocumentId: shippingDocumentId ?? this.shippingDocumentId,
|
||||||
|
|||||||
@@ -141,6 +141,55 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Formatta in "GG/MM/AAAA HH:MM"
|
||||||
|
String _formatDateTime(DateTime dt) {
|
||||||
|
return "${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lancia i popup di Data e poi di Ora
|
||||||
|
Future<void> _selectDeliveryDate(
|
||||||
|
BuildContext context,
|
||||||
|
TicketModel ticket,
|
||||||
|
) async {
|
||||||
|
final initialDate = ticket.estimatedDeliveryAt ?? DateTime.now();
|
||||||
|
|
||||||
|
// 1. Chiediamo la Data
|
||||||
|
final pickedDate = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: initialDate,
|
||||||
|
firstDate: DateTime(
|
||||||
|
2020,
|
||||||
|
), // Oppure DateTime.now() se non vuoi date passate
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pickedDate == null) return; // L'utente ha annullato
|
||||||
|
|
||||||
|
// 2. Chiediamo l'Ora
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final pickedTime = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: TimeOfDay.fromDateTime(initialDate),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pickedTime == null) return; // L'utente ha annullato
|
||||||
|
|
||||||
|
// 3. Fondiamo Data e Ora in un unico DateTime
|
||||||
|
final finalDateTime = DateTime(
|
||||||
|
pickedDate.year,
|
||||||
|
pickedDate.month,
|
||||||
|
pickedDate.day,
|
||||||
|
pickedTime.hour,
|
||||||
|
pickedTime.minute,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Aggiorniamo il Cubit
|
||||||
|
if (!context.mounted) return;
|
||||||
|
context.read<TicketFormCubit>().updateFields(
|
||||||
|
estimatedDeliveryAt: finalDateTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _generateIdForQr() async {
|
Future<String?> _generateIdForQr() async {
|
||||||
// 1. Validiamo i campi obbligatori (es. il cliente)
|
// 1. Validiamo i campi obbligatori (es. il cliente)
|
||||||
if (!_formKey.currentState!.validate()) return null;
|
if (!_formKey.currentState!.validate()) return null;
|
||||||
@@ -299,6 +348,32 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
|
|||||||
trackingCubit.loadTrackings(ticketId, TrackingParentType.ticket);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -310,6 +385,10 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
|
|||||||
_syncTextControllers(state.ticket);
|
_syncTextControllers(state.ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.status == TicketFormStatus.deleted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
if (state.status == TicketFormStatus.success) {
|
if (state.status == TicketFormStatus.success) {
|
||||||
context.read<TicketListCubit>().loadTickets(refresh: true);
|
context.read<TicketListCubit>().loadTickets(refresh: true);
|
||||||
_showSuccessActions(
|
_showSuccessActions(
|
||||||
@@ -339,66 +418,61 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
|
|||||||
: 'Modifica Ticket - Operatore: ${state.ticket.createdByName}',
|
: 'Modifica Ticket - Operatore: ${state.ticket.createdByName}',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
BlocBuilder<TicketFormCubit, TicketFormState>(
|
if (ticket.id != null) ...[
|
||||||
builder: (context, state) {
|
Padding(
|
||||||
final ticket = state.ticket;
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16.0,
|
||||||
// Se il ticket non è ancora salvato, niente azioni rapide
|
vertical: 8.0,
|
||||||
if (ticket.id == null || ticket.id!.isEmpty) {
|
),
|
||||||
return const SizedBox.shrink();
|
child: FilledButton.icon(
|
||||||
}
|
onPressed: () => _deleteTicket(ticket, color: Colors.red),
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
// CONDIZIONE A: Da iniziare
|
label: const Text('Cancella Ticket'),
|
||||||
if (ticket.ticketStatus == TicketStatus.open ||
|
),
|
||||||
ticket.ticketStatus == TicketStatus.waitingForParts) {
|
),
|
||||||
return Padding(
|
],
|
||||||
padding: const EdgeInsets.symmetric(
|
if (ticket.ticketStatus == TicketStatus.open ||
|
||||||
horizontal: 16.0,
|
ticket.ticketStatus == TicketStatus.waitingForParts) ...[
|
||||||
vertical: 8.0,
|
// CONDIZIONE A: Da iniziare
|
||||||
),
|
Padding(
|
||||||
child: FilledButton.icon(
|
padding: const EdgeInsets.symmetric(
|
||||||
style: FilledButton.styleFrom(
|
horizontal: 16.0,
|
||||||
backgroundColor:
|
vertical: 8.0,
|
||||||
Colors.amber.shade700, // Colore Action
|
),
|
||||||
),
|
child: FilledButton.icon(
|
||||||
onPressed: () async {
|
style: FilledButton.styleFrom(
|
||||||
StaffMemberModel? takenBy = await getStaffMember(
|
backgroundColor: Colors.amber.shade700, // Colore Action
|
||||||
context,
|
),
|
||||||
);
|
onPressed: () async {
|
||||||
if (takenBy == null || !context.mounted) return;
|
StaffMemberModel? takenBy = await getStaffMember(context);
|
||||||
context.read<TicketFormCubit>().takeInCharge(
|
if (takenBy == null || !context.mounted) return;
|
||||||
staffId: takenBy.id!,
|
context.read<TicketFormCubit>().takeInCharge(
|
||||||
staffName: takenBy.name,
|
staffId: takenBy.id!,
|
||||||
);
|
staffName: takenBy.name,
|
||||||
_navigateToWorkspace(ticket.id!);
|
);
|
||||||
},
|
_navigateToWorkspace(ticket.id!);
|
||||||
icon: const Icon(Icons.play_arrow, color: Colors.white),
|
},
|
||||||
label: const Text(
|
icon: const Icon(Icons.play_arrow, color: Colors.white),
|
||||||
'Prendi in Carico',
|
label: const Text(
|
||||||
style: TextStyle(color: Colors.white),
|
'Prendi in Carico',
|
||||||
),
|
style: TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
// CONDIZIONE B: Già in lavorazione
|
],
|
||||||
else if (ticket.ticketStatus == TicketStatus.inProgress) {
|
if (ticket.ticketStatus == TicketStatus.inProgress) ...[
|
||||||
return Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16.0,
|
horizontal: 16.0,
|
||||||
vertical: 8.0,
|
vertical: 8.0,
|
||||||
),
|
),
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: () => _navigateToWorkspace(ticket.id!),
|
onPressed: () => _navigateToWorkspace(ticket.id!),
|
||||||
icon: const Icon(Icons.handyman),
|
icon: const Icon(Icons.handyman),
|
||||||
label: const Text('Vai a Lavorazione'),
|
label: const Text('Vai a Lavorazione'),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
],
|
||||||
|
|
||||||
// Se è chiuso o in altri stati strani, nascondiamo il bottone
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 16.0),
|
padding: const EdgeInsets.only(right: 16.0),
|
||||||
child: Chip(
|
child: Chip(
|
||||||
@@ -784,6 +858,7 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<TicketType>(
|
child: DropdownButtonFormField<TicketType>(
|
||||||
|
isExpanded: true,
|
||||||
initialValue: ticket.ticketType,
|
initialValue: ticket.ticketType,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Tipo Lavorazione',
|
labelText: 'Tipo Lavorazione',
|
||||||
@@ -804,6 +879,7 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<TicketStatus>(
|
child: DropdownButtonFormField<TicketStatus>(
|
||||||
|
isExpanded: true,
|
||||||
initialValue: ticket.ticketStatus,
|
initialValue: ticket.ticketStatus,
|
||||||
decoration: const InputDecoration(labelText: 'Stato Attuale'),
|
decoration: const InputDecoration(labelText: 'Stato Attuale'),
|
||||||
items: TicketStatus.values
|
items: TicketStatus.values
|
||||||
@@ -815,6 +891,37 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
readOnly: true, // MAGIA: Impedisce l'apertura della tastiera
|
||||||
|
// Creiamo un controller "al volo" solo per mostrargli la stringa
|
||||||
|
controller: TextEditingController(
|
||||||
|
text: ticket.estimatedDeliveryAt != null
|
||||||
|
? _formatDateTime(ticket.estimatedDeliveryAt!)
|
||||||
|
: '',
|
||||||
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Riconsegna prevista (Data e Ora)',
|
||||||
|
prefixIcon: const Icon(Icons.event_available),
|
||||||
|
// Bottone con la X per rimuovere la data se il cliente ti dice "fai con calma"
|
||||||
|
suffixIcon: ticket.estimatedDeliveryAt != null
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
// NOTA: Dovrai assicurarti che il tuo Cubit gestisca il reset.
|
||||||
|
// O passi un flag come clearEstimatedDelivery: true,
|
||||||
|
// o gestisci il null se il tuo updateFields lo permette.
|
||||||
|
context.read<TicketFormCubit>().updateFields(
|
||||||
|
clearEstimatedDelivery:
|
||||||
|
true, // Esempio di flag da aggiungere nel Cubit
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
// Quando tappi il campo di testo, partono i calendari
|
||||||
|
onTap: () => _selectDeliveryDate(context, ticket),
|
||||||
|
),
|
||||||
if (ticket.ticketType == TicketType.repair) ...[
|
if (ticket.ticketType == TicketType.repair) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<WarrantyType>(
|
DropdownButtonFormField<WarrantyType>(
|
||||||
@@ -1001,11 +1108,15 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
|
|||||||
child: Icon(icon, color: themeColor),
|
child: Icon(icon, color: themeColor),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Expanded(
|
||||||
title,
|
child: Text(
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
title,
|
||||||
fontWeight: FontWeight.bold,
|
maxLines: 1,
|
||||||
color: themeColor,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: themeColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ class _TicketListScreenState extends State<TicketListScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Assistenza & Riparazioni'),
|
title: const Text('Assistenza & Riparazioni'),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () =>
|
||||||
|
context.read<TicketListCubit>().loadTickets(refresh: true),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
),
|
||||||
// Tasto per filtri avanzati (Data, Staff, Tipo) -> Da fare in un BottomSheet!
|
// Tasto per filtri avanzati (Data, Staff, Tipo) -> Da fare in un BottomSheet!
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.filter_list),
|
icon: const Icon(Icons.filter_list),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user