57 Commits

Author SHA1 Message Date
99ab7abf6e adfadsf 2026-06-03 13:20:22 +02:00
a7fd37a894 buono 2026-06-03 12:08:59 +02:00
8ad2b7cf7e operation reference not mandatory
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m57s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m14s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 8m52s
2026-06-02 13:28:24 +02:00
3210b4fcfa default provider
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m59s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m22s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-06-02 13:12:21 +02:00
a51ac8fe7f ticket refinements 2026-06-02 11:52:31 +02:00
7fad6ee02b pw reset con edge function
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m24s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m14s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-06-02 10:55:59 +02:00
6a6e792cd9 fixed reset password 2026-06-02 10:55:26 +02:00
3c33c8765a v
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m12s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m24s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-06-02 10:20:43 +02:00
27a5bc16bc ricostruzione sessione manuale per aggirare gorouter che distrugge il token 2026-06-02 10:20:25 +02:00
808de7b354 altra prova
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m54s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m7s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-06-02 09:39:45 +02:00
618cbc0396 prova per inviti
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m28s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m31s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-06-02 09:32:30 +02:00
67a56f2954 fix per notifiche su web 2026-06-01 10:55:28 +02:00
88b1a618bd deep link from dead app
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m41s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m12s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 7m58s
2026-06-01 10:08:44 +02:00
d989b14967 bump
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m54s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m18s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 7m59s
2026-05-31 19:06:04 +02:00
d4ff2b9a7e task notifications 2026-05-31 19:05:21 +02:00
06ee11521d v1.1.6 2026-05-31 19:04:48 +02:00
55d6429dc5 bump version
Some checks failed
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
Build and Release FLUX (Multi-Platform) / build-web (push) Has been cancelled
Build and Release FLUX (Multi-Platform) / build-android (push) Has been cancelled
2026-05-30 18:10:10 +02:00
44c85766fc notes cubit 2026-05-30 18:06:43 +02:00
b69308e1ef refactor dashboard note list 2026-05-30 16:45:12 +02:00
6394e5a2cd refactor dashboard store ticket list 2026-05-30 16:26:59 +02:00
f31ff19a74 refactor dashboard operation list e task list with applifecycle 2026-05-30 15:19:22 +02:00
064179a753 bumped version
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m45s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m9s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 8m12s
2026-05-30 13:07:21 +02:00
727eaac3d9 resend e fcm 2026-05-30 12:26:53 +02:00
bd81173559 fcm 2026-05-30 12:12:14 +02:00
9bace01b93 b 2026-05-29 19:24:40 +02:00
5ad3e12b1f b 2026-05-29 12:26:41 +02:00
6211cc6729 bump....fixato sharedattachments e notecollaborators
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m1s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m21s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 4m15s
2026-05-28 23:49:55 +02:00
f15a2aa6e6 fixes 2026-05-28 23:48:30 +02:00
aed841dc0b firma su exe windows
All checks were successful
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 5m1s
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m52s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m6s
2026-05-28 18:06:10 +02:00
221260aca3 v
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m10s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m18s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 5m34s
2026-05-28 13:57:11 +02:00
83988597d5 tasks 2026-05-28 13:55:28 +02:00
b298509178 a 2026-05-27 19:38:59 +02:00
b6e5f9acbe x 2026-05-27 16:00:50 +02:00
f6ecb33729 refactor: replace string literals with table constants in TaskRepository 2026-05-27 08:41:53 +02:00
9d796d6e41 boh 2026-05-26 19:31:25 +02:00
45455a16c4 w 2026-05-26 12:28:12 +02:00
2afe97c6db spostato aggiornamento tabella supabase sul worker del mac anche per FluxInstaller.exe
All checks were successful
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 3m20s
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m44s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m2s
2026-05-25 16:45:51 +02:00
4101b736e6 fix windows deployment
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m12s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m3s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 3m52s
2026-05-25 16:34:23 +02:00
b67354610d prova x sistemare pipeline windows
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m31s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m3s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 3m32s
2026-05-25 15:55:53 +02:00
b19c91a7dd refinements
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m56s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m9s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 5m9s
2026-05-25 14:29:48 +02:00
9b5d19b926 refinements 2026-05-25 12:49:04 +02:00
aad9a991c2 v
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m36s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m8s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 4m22s
2026-05-24 13:35:59 +02:00
7f0d18eed1 aggiorna link aggiornamenti
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m47s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m8s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-05-24 12:51:16 +02:00
879c848d77 v
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m52s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m13s
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
2026-05-24 12:42:11 +02:00
123c006a1e changed navigation 2026-05-24 10:25:16 +02:00
415811f592 app shell 2026-05-24 09:49:07 +02:00
31066a4d8f v
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m28s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m2s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 4m1s
2026-05-23 17:16:51 +02:00
b700c2de8d w
Some checks failed
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 3m4s
Build and Release FLUX (Multi-Platform) / build-android (push) Failing after 15m38s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m4s
2026-05-23 16:50:56 +02:00
fda5b8fe2e v
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m24s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m2s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 3m56s
2026-05-23 13:46:27 +02:00
b7a525056a v
Some checks failed
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 1m58s
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m56s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m30s
2026-05-23 11:08:59 +02:00
7a11e829b3 a 2026-05-23 11:08:39 +02:00
361b61a694 pub upgrade 2026-05-23 10:18:20 +02:00
0cb060c89c aggiunta build web e android
Some checks failed
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
Build and Release FLUX (Multi-Platform) / build-android (push) Has been cancelled
Build and Release FLUX (Multi-Platform) / build-web (push) Has been cancelled
2026-05-22 11:44:41 +02:00
4b9cbf65f9 using dep override pdfx da github
Some checks failed
Build and Release FLUX Windows / build (push) Has been cancelled
2026-05-22 11:06:19 +02:00
813fc9dd38 prova
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 26s
2026-05-22 10:45:46 +02:00
f574d6197b provato ad aggiustare pipeline windows
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 49s
2026-05-22 10:35:52 +02:00
2fac3117a4 version 2026-05-22 10:23:10 +02:00
123 changed files with 8647 additions and 2158 deletions

View File

@@ -1,52 +1,115 @@
name: Build and Release FLUX Windows name: Build and Release FLUX (Multi-Platform)
on: on:
push: push:
tags: tags:
- 'v*' # Si attiva solo quando crei un tag tipo v1.0.0 - 'v*'
jobs: jobs:
build: # -----------------------------------------------------------------
runs-on: windows-native # Richiama esattamente l'etichetta del PC del collega # JOB 1: WINDOWS (Gira sul PC del collega appena si libera)
# -----------------------------------------------------------------
build-windows:
runs-on: windows-native
steps: steps:
- name: Checkout del codice - name: Checkout del codice
uses: actions/checkout@v3 uses: actions/checkout@v3
# 1. Facciamo una build finta. Genererà i symlinks e poi fallirà per il bug, - name: Crea file .env
# ma il 'continue-on-error' dirà a Gitea di ignorare l'errore e andare avanti. run: |
- name: Pre-build per generare l'ambiente (Fallirà apposta) Set-Content -Path ".env" -Value "${{ secrets.ENV_FILE_CONTENT }}"
continue-on-error: true
- name: Build Flutter Windows
run: flutter build windows --release run: flutter build windows --release
# 2. Ora la cartella ephemeral esiste! Applichiamo il fix. # 1. FIRMA DELL'ESEGUIBILE RAW
- name: Fix bug CMake di pdfx - name: Firma Eseguibile Flutter
run: | run: |
$cmakeFile = "windows\flutter\ephemeral\.plugin_symlinks\pdfx\windows\DownloadProject.cmake" # 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
if (Test-Path $cmakeFile) { # Sostituisci il percorso con dove hai salvato fisicamente il file .pfx sul PC del runner!
(Get-Content $cmakeFile) -replace 'VERSION 2.8.2', 'VERSION 3.5' | Set-Content $cmakeFile & $Signtool sign /f "C:\flux_privato.pfx" /p "${{ secrets.PFX_PASSWORD }}" /fd SHA256 "build\windows\x64\runner\Release\flux.exe"
Write-Host "Il cerotto CMake è stato applicato con successo!"
} else {
Write-Error "File non trovato. La pre-build non ha generato i symlink."
exit 1
}
# 3. Ora che il file è corretto, la build vera arriverà fino in fondo - name: Build Windows Installer
- name: Build Flutter Definitiva
env:
CMAKE_BUILD_PARALLEL_LEVEL: 2
run: flutter build windows --release
- name: Compila Installer Inno Setup
run: | run: |
$TagVersion = "${{ gitea.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"
# Usa un'action standard per caricare il file su Gitea Releases # 2. FIRMA DELL'INSTALLER GENERATO DA INNO SETUP
- name: Upload Release Asset - name: Firma Installer
run: |
$Signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1).FullName
& $Signtool sign /f "C:\flux_privato.pfx" /p "${{ secrets.PFX_PASSWORD }}" /fd SHA256 "build\windows\installer\FluxInstaller.exe"
# Nel dubbio usiamo l'action per caricare l'asset
- name: Upload Windows Asset
uses: https://gitea.com/actions/release-action@main uses: https://gitea.com/actions/release-action@main
with: with:
files: "build/windows/installer/FLUX_Setup_x64.exe" files: "build/windows/installer/FluxInstaller.exe"
api_key: ${{ secrets.GITEA_TOKEN }} api_key: ${{ secrets.MYRELEASE_TOKEN }}
- name: Pulisci Workspace
if: always() # Esegue questo step anche se la build fallisce - name: Pulisci Workspace Windows
if: always()
run: Remove-Item -Recurse -Force ./* run: Remove-Item -Recurse -Force ./*
# -----------------------------------------------------------------
# JOB 2: ANDROID APK (Gira sul tuo MacBook)
# -----------------------------------------------------------------
build-android:
runs-on: macos-runner # <--- Etichetta del tuo Mac
steps:
- name: Checkout del codice
uses: actions/checkout@v3
# Logica Bash per Mac: usiamo le virgolette singole forti per evitare escape strani
- name: Crea file .env
run: |
cat << 'EOF' > .env
${{ secrets.ENV_FILE_CONTENT }}
EOF
- name: Build Flutter APK
run: flutter build apk --release
# Carichiamo l'APK universale o quelli splittati nelle release di Gitea
- name: Upload Android Asset
uses: https://gitea.com/actions/release-action@main
with:
files: "build/app/outputs/flutter-apk/app-release.apk"
api_key: ${{ secrets.MYRELEASE_TOKEN }}
- name: Aggiorna Link Android su Supabase
run: |
curl -X PATCH "https://pvqpjloswwvtfoxbkfbh.supabase.co/rest/v1/app_config?id=eq.49f18b19-2129-46c0-b690-a97db725b5a8" -H "apikey: ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Content-Type: application/json" -d "{\"download_url\": \"https://gitea.catelli.it/brontomark/flux/releases/download/${{ github.ref_name }}/app-release.apk\"}"
- name: Aggiorna Link Windows su Supabase
run: |
curl -X PATCH "https://pvqpjloswwvtfoxbkfbh.supabase.co/rest/v1/app_config?id=eq.1f888b30-5cbf-4a16-820c-5036a3af0cf8" -H "apikey: ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Content-Type: application/json" -d "{\"download_url\": \"https://gitea.catelli.it/brontomark/flux/releases/download/${{ github.ref_name }}/FluxInstaller.exe\"}"
# -----------------------------------------------------------------
# JOB 3: WEB & CLOUDFLARE DEPLOY (Gira sul tuo MacBook)
# -----------------------------------------------------------------
build-web:
runs-on: macos-runner # <--- Etichetta del tuo Mac
steps:
- name: Checkout del codice
uses: actions/checkout@v3
- name: Crea file .env
run: |
cat << 'EOF' > .env
${{ secrets.ENV_FILE_CONTENT }}
EOF
- name: Build Flutter Web
run: flutter build web --release
# Sfruttiamo npx (incluso in Node.js) per lanciare wrangler al volo senza installarlo globalmente
# Sto assumendo che usi Cloudflare Pages che è perfetto per Flutter Web statico
- name: Deploy su Cloudflare Pages
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler pages deploy build/web --project-name="flux" --branch="main"

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

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

View File

@@ -7,6 +7,10 @@ analyzer:
- "lib/l10n/*.dart" - "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:

View File

@@ -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")

View File

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

View File

@@ -20,6 +20,9 @@ pluginManagement {
plugins { 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
View File

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

View File

@@ -7,6 +7,7 @@
objects = { 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;
}; };

View File

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

View File

@@ -1,9 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'package:equatable/equatable.dart'; import 'package: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,35 +45,28 @@ 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
emit(state.copyWith(status: SessionStatus.initial, errorMessage: null));
// WRAP DELLA LOGICA IN UN BLOCCO PROTETTO DA TIMEOUT (10 Secondi)
await Future(() async {
StaffMemberModel? staff = await _repository.getStaffMemberByUserId( StaffMemberModel? staff = await _repository.getStaffMemberByUserId(
user.id, user.id,
); );
CompanyModel? company; CompanyModel? company;
if (staff != null) { if (staff != null) {
// --- LA MAGIA DEL SENSORE ---
if (staff.hasJoined == false) { if (staff.hasJoined == false) {
// È la primissima volta che entra! Aggiorniamo il DB. await _repository.updateStaffMember(staff.id!, {
await _repository.updateStaffMember(staff.id!, {'has_joined': true}); 'has_joined': true,
// Aggiorniamo anche il nostro modello in memoria per questa sessione });
staff = staff.copyWith(hasJoined: true); staff = staff.copyWith(hasJoined: true);
} }
company = await _repository.getCompanyById(staff.companyId); company = await _repository.getCompanyById(staff.companyId);
} else { } else {
// È l'Admin in onboarding
company = await _repository.getCompanyByOwnerId(user.id); company = await _repository.getCompanyByOwnerId(user.id);
} }
// 1. Controllo Azienda
if (staff != null) {
// L'utente esiste già nel sistema! Carichiamo l'azienda per cui lavora
company = await _repository.getCompanyById(staff.companyId);
} else {
// L'utente non ha profilo. Probabilmente è l'Admin che ha appena
// fatto Sign Up e sta iniziando l'Onboarding
company = await _repository.getCompanyByOwnerId(user.id);
}
if (company == null) { if (company == null) {
return emit( return emit(
state.copyWith( state.copyWith(
@@ -80,7 +79,6 @@ class SessionCubit extends Cubit<SessionState> {
emit(state.copyWith(company: company)); emit(state.copyWith(company: company));
} }
// 2. Controllo Negozi
final stores = await _repository.getStoresByCompanyId(company.id!); final stores = await _repository.getStoresByCompanyId(company.id!);
if (stores.isEmpty) { if (stores.isEmpty) {
return emit( return emit(
@@ -95,7 +93,6 @@ class SessionCubit extends Cubit<SessionState> {
emit(state.copyWith(currentStore: stores.first)); emit(state.copyWith(currentStore: stores.first));
} }
// 3. Controllo Staff (Paziente Zero)
if (staff == null) { if (staff == null) {
return emit( return emit(
state.copyWith( state.copyWith(
@@ -107,23 +104,16 @@ class SessionCubit extends Cubit<SessionState> {
); );
} }
// --- TUTTO COMPLETATO: LOGICA DEL NEGOZIO DI DEFAULT ---
// Leggiamo l'ultimo negozio dalle SharedPreferences
final lastStoreId = _prefs.getString(_lastStoreKey); final lastStoreId = _prefs.getString(_lastStoreKey);
// Cerchiamo quel negozio nella lista. Se non c'è (magari è stato eliminato), prendiamo il primo.
final activeStore = final activeStore =
stores.firstWhereOrNull((s) => s.id == lastStoreId) ?? stores.first; stores.firstWhereOrNull((s) => s.id == lastStoreId) ?? stores.first;
// Se non avevamo il lastStoreId salvato, salviamolo ora
if (lastStoreId != activeStore.id && activeStore.id != null) { if (lastStoreId != activeStore.id && activeStore.id != null) {
await _prefs.setString(_lastStoreKey, activeStore.id!); await _prefs.setString(_lastStoreKey, activeStore.id!);
} }
setIsSingleUserMode(_prefs.getBool('isSingleUserMode') ?? false); setIsSingleUserMode(_prefs.getBool('isSingleUserMode') ?? false);
// 4. BENVENUTO A BORDO
emit( emit(
state.copyWith( state.copyWith(
status: SessionStatus.authenticated, status: SessionStatus.authenticated,
@@ -131,20 +121,107 @@ class SessionCubit extends Cubit<SessionState> {
company: company, company: company,
currentStore: activeStore, currentStore: activeStore,
currentStaffMember: staff, currentStaffMember: staff,
onboardingStep: OnboardingStep.none, // Svuotiamo l'onboarding onboardingStep: OnboardingStep.none,
), ),
); );
// FCM è fuori dall'await principale, quindi va bene così
_registerFcmToken(companyId: company.id!, staffId: staff.id!);
}).timeout(
const Duration(seconds: 10), // Tempo massimo concesso al server
onTimeout: () {
throw TimeoutException(
'Il server di FLUX non risponde. Controlla la connessione.',
);
},
);
} on TimeoutException catch (e) {
// 🎯 BINGO! IL TIMEOUT È SCATTATO
debugPrint("Timeout Inizializzazione: ${e.message}");
emit(
state.copyWith(status: SessionStatus.error, errorMessage: e.message),
);
} catch (e) { } 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));
}
}
} }

View File

@@ -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

View File

@@ -16,6 +16,8 @@ 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 tickets = 'tickets'; static const String tickets = 'tickets';
static const String trackings = 'trackings'; static const String trackings = 'trackings';
} }

View File

@@ -1,97 +1,401 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/utils/extensions.dart'; import 'package:flux/core/utils/extensions.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
// ==========================================
// 1. IL GUSCIO (QUELLO CHE PASSI AL ROUTER)
// ==========================================
class AppShell extends StatelessWidget { class AppShell extends StatelessWidget {
final Widget child; final Widget child;
const AppShell({super.key, required this.child}); const AppShell({super.key, required this.child});
// Calcoliamo l'indice attivo in base all'URL corrente!
int _calculateSelectedIndex(BuildContext context) {
final String location = GoRouterState.of(context).uri.path;
if (location.startsWith('/master-data')) return 1;
if (location.startsWith('/settings')) return 2;
return 0; // Default: Dashboard
}
void _onItemTapped(int index, BuildContext context) {
switch (index) {
case 0:
context.go('/');
break;
case 1:
context.go('/master-data');
break;
case 2:
context.go('/settings');
break;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentIndex = _calculateSelectedIndex(context); // Breakpoint a 900px: sotto è Mobile/Tablet (Drawer), sopra è Desktop (Sidebar)
// Breakpoint: se lo schermo è più largo di 600px, usiamo la Rail laterale final isDesktop = MediaQuery.sizeOf(context).width >= 900;
final isDesktop = MediaQuery.sizeOf(context).width >= 600; final currentPath = GoRouterState.of(context).uri.path;
return Scaffold( return Scaffold(
// Su mobile usiamo un'AppBar minimale per avere il bottone "Hamburger" nativo
appBar: isDesktop
? null
: AppBar(
title: const Text(
"FLUX",
style: TextStyle(fontWeight: FontWeight.bold),
),
centerTitle: true,
elevation: 0,
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
),
drawer: isDesktop
? null
: Drawer(
// Su mobile inietta il menu qui!
child: AppMenu(currentPath: currentPath, isDrawer: true),
),
body: isDesktop body: isDesktop
? Row( ? Row(
children: [ children: [
NavigationRail( // Su desktop inietta il menu a sinistra!
selectedIndex: currentIndex, AppMenu(currentPath: currentPath, isDrawer: false),
onDestinationSelected: (index) =>
_onItemTapped(index, context),
labelType: NavigationRailLabelType.all,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: Text(context.l10n.commonDashboard),
),
NavigationRailDestination(
icon: Icon(Icons.folder_special_outlined),
selectedIcon: Icon(Icons.folder_special),
label: Text(context.l10n.commonMasterData),
),
NavigationRailDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: Text(context.l10n.commonSettings),
),
],
),
const VerticalDivider(thickness: 1, width: 1), const VerticalDivider(thickness: 1, width: 1),
// Il contenuto della pagina
Expanded(child: child), Expanded(child: child),
], ],
) )
: child, // Su mobile il contenuto prende tutto lo schermo... : child, // Su mobile il child prende tutto lo schermo sotto l'AppBar
// ... e mettiamo la barra in basso! );
bottomNavigationBar: isDesktop }
? null }
: NavigationBar(
selectedIndex: currentIndex, class AppMenu extends StatefulWidget {
onDestinationSelected: (index) => _onItemTapped(index, context), final String currentPath; // Lo usiamo ancora per capire cosa accendere
destinations: [ final bool isDrawer;
NavigationDestination(
icon: Icon(Icons.dashboard_outlined), const AppMenu({super.key, required this.currentPath, required this.isDrawer});
selectedIcon: Icon(Icons.dashboard),
label: context.l10n.commonDashboard, @override
State<AppMenu> createState() => _AppMenuState();
}
class _AppMenuState extends State<AppMenu> {
bool _isCollapsed = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bool effectivelyCollapsed = _isCollapsed && !widget.isDrawer;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: effectivelyCollapsed ? 72 : 260,
child: SafeArea(
child: Column(
children: [
// --- HEADER ---
Container(
height: 80,
padding: const EdgeInsets.symmetric(horizontal: 20.0),
alignment: effectivelyCollapsed
? Alignment.center
: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bolt, color: theme.colorScheme.primary, size: 32),
if (!effectivelyCollapsed) ...[
const SizedBox(width: 12),
TextButton(
onPressed: () {
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(Routes.home);
},
child: Text(
"FLUX",
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
), ),
NavigationDestination(
icon: Icon(Icons.folder_special_outlined),
selectedIcon: Icon(Icons.folder_special),
label: context.l10n.commonMasterData,
), ),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: context.l10n.commonSettings,
), ),
], ],
],
),
),
// --- VOCI DI MENU ---
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: SizedBox(
width: effectivelyCollapsed ? 72 : 260,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
children: [
_buildRouteItem(
title: 'Dashboard',
icon: Icons.dashboard_outlined,
routeName: Routes.home,
pathToCheck:
'/', // Il path da controllare per colorarlo
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),
// --- IL MENU GERARCHICO (ANAGRAFICHE) ---
_buildHierarchicalItem(
title: context.l10n.commonMasterData,
icon: Icons.folder_special_outlined,
basePathToCheck:
'/master-data', // Se il path inizia così, espandi
isCollapsed: effectivelyCollapsed,
subItems: [
_SubMenuItem(
"Clienti",
Routes.customers,
'/master-data/customers',
),
_SubMenuItem(
"Fornitori",
Routes.providers,
'/master-data/providers',
),
_SubMenuItem(
"Prodotti",
Routes.products,
'/master-data/products',
),
],
),
const SizedBox(height: 8),
_buildRouteItem(
title: context.l10n.commonSettings,
icon: Icons.settings_outlined,
routeName: Routes.settings,
pathToCheck: '/settings',
isCollapsed: effectivelyCollapsed,
),
],
),
),
),
),
// --- PULSANTE TOGGLE (Solo Desktop) ---
if (!widget.isDrawer)
Padding(
padding: const EdgeInsets.all(8.0),
child: IconButton(
tooltip: _isCollapsed ? 'Espandi Menu' : 'Riduci Menu',
icon: Icon(
_isCollapsed
? Icons.keyboard_double_arrow_right
: Icons.keyboard_double_arrow_left,
color: theme.iconTheme.color?.withValues(alpha: 0.5),
),
onPressed: () {
setState(() {
_isCollapsed = !_isCollapsed;
});
},
),
),
],
),
),
);
}
// ==========================================
// WIDGET HELPER AGGIORNATI
// ==========================================
Widget _buildRouteItem({
required String title,
required IconData icon,
required String routeName,
required String pathToCheck,
required bool isCollapsed,
}) {
final isSelected = widget.currentPath == pathToCheck;
final theme = Theme.of(context);
if (isCollapsed) {
return Tooltip(
message: title,
preferBelow: false,
child: InkWell(
onTap: () {
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(routeName); // <--- goNamed!
},
borderRadius: BorderRadius.circular(8),
child: Container(
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: isSelected ? theme.colorScheme.primary : null,
),
),
),
);
}
return ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
selectedTileColor: theme.colorScheme.primaryContainer.withValues(
alpha: 0.4,
),
selected: isSelected,
leading: Icon(icon, color: isSelected ? theme.colorScheme.primary : null),
title: Text(
title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.clip,
),
onTap: () {
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(routeName); // <--- goNamed!
},
);
}
Widget _buildHierarchicalItem({
required String title,
required IconData icon,
required String basePathToCheck,
required bool isCollapsed,
required List<_SubMenuItem> subItems,
}) {
final isSelected = subItems.any(
(item) => widget.currentPath.startsWith(item.pathToCheck),
);
final theme = Theme.of(context);
if (isCollapsed) {
return PopupMenuButton<String>(
tooltip: title,
offset: const Offset(60, 0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (routeName) {
// Il routeName arriva dal value del menu
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(routeName); // <--- goNamed!
},
itemBuilder: (context) => subItems
.map(
(item) => PopupMenuItem(
value: item
.routeName, // Passiamo il nome della rotta (Routes.customers)
child: Text(
item.title,
style: TextStyle(
fontWeight: widget.currentPath == item.pathToCheck
? FontWeight.bold
: FontWeight.normal,
color: widget.currentPath == item.pathToCheck
? theme.colorScheme.primary
: null,
),
),
),
)
.toList(),
child: Container(
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: isSelected ? theme.colorScheme.primary : null,
),
),
);
}
return Theme(
data: theme.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: isSelected,
maintainState: true,
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
leading: Icon(
icon,
color: isSelected ? theme.colorScheme.primary : null,
),
title: Text(
title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.clip,
),
children: subItems.map((item) {
final isSubSelected = widget.currentPath == item.pathToCheck;
return Padding(
padding: const EdgeInsets.only(left: 32.0, bottom: 4.0),
child: ListTile(
dense: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
selectedTileColor: theme.colorScheme.primaryContainer.withValues(
alpha: 0.2,
),
selected: isSubSelected,
title: Text(
item.title,
style: TextStyle(
fontWeight: isSubSelected
? FontWeight.bold
: FontWeight.normal,
color: isSubSelected
? theme.colorScheme.primary
: theme.textTheme.bodyMedium?.color,
),
maxLines: 1,
overflow: TextOverflow.clip,
),
onTap: () {
if (widget.isDrawer) Navigator.pop(context);
context.goNamed(item.routeName); // <--- goNamed!
},
),
);
}).toList(),
), ),
); );
} }
} }
// Struttura dati per le voci dei sottomenu aggiornata
class _SubMenuItem {
final String title;
final String routeName; // Es: Routes.customers
final String pathToCheck; // Es: '/master-data/customers'
_SubMenuItem(this.title, this.routeName, this.pathToCheck);
}

View File

@@ -7,9 +7,9 @@ import 'package:flux/core/layout/app_shell.dart';
import 'package:flux/core/routes/routes.dart'; import 'package:flux/core/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';
@@ -126,78 +145,133 @@ class AppRouter {
ShellRoute( ShellRoute(
builder: (context, state, child) => AppShell(child: child), builder: (context, state, child) => AppShell(child: child),
routes: [ routes: [
// ==========================================
// 1. DASHBOARD // 1. DASHBOARD
// ==========================================
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(),
);
},
), ),
// ==========================================
// 2. HUB ANAGRAFICHE E SOTTO-ROTTE // 2. HUB ANAGRAFICHE E SOTTO-ROTTE
// ==========================================
GoRoute( GoRoute(
path: '/master-data', path: '/master-data',
name: Routes.masterData, name: Routes.masterData,
builder: (context, state) => const MasterDataHubScreen(), builder: (context, state) => const MasterDataHubScreen(),
routes: [ routes: [
GoRoute( GoRoute(
path: 'products', // Diventa /master-data/products path:
'customers', // Niente slash iniziale per le sottorotte! -> /master-data/customers
name: Routes.customers,
builder: (context, state) => const CustomersListScreen(),
),
GoRoute(
path: 'providers', // -> /master-data/providers
name: Routes.providers,
builder: (context, state) => const ProviderListScreen(),
),
GoRoute(
path: 'products', // -> /master-data/products
name: Routes.products, name: Routes.products,
builder: (context, state) { builder: (context, state) {
context.read<ProductsCubit>().refreshCubit(); context.read<ProductsCubit>().refreshCubit();
return const ProductsScreen(); return const ProductsScreen();
}, },
), ),
GoRoute( GoRoute(
path: 'company-settings', path: 'staff', // -> /master-data/staff
name: Routes.staff,
builder: (context, state) => const StaffScreen(),
),
GoRoute(
path:
'stores', // Sistemata l'inversione path/name -> /master-data/stores
name: Routes.stores,
builder: (context, state) {
context.read<ProviderListCubit>().loadAllProviders();
context.read<StoreCubit>().loadStores();
return const StoresScreen();
},
),
GoRoute(
path: 'company-settings', // -> /master-data/company-settings
name: Routes.companySettings, name: Routes.companySettings,
builder: (context, state) => BlocProvider( builder: (context, state) => BlocProvider(
create: (context) => CompanySettingsCubit(), create: (context) => CompanySettingsCubit(),
child: const CompanySettingsScreen(), child: const CompanySettingsScreen(),
), ),
), ),
GoRoute(
path: 'staff',
name: Routes.staff, // Diventa /master-data/staff
builder: (context, state) => const StaffScreen(),
),
GoRoute(
path: Routes.stores,
name: 'stores', // Diventa /master-data/stores
builder: (context, state) => const StoresScreen(),
),
GoRoute(
path: '/providers',
name: Routes.providers,
builder: (context, state) => const ProviderListScreen(),
),
], ],
), ),
// ==========================================
// 3. IMPOSTAZIONI // 3. IMPOSTAZIONI
// ==========================================
GoRoute( GoRoute(
path: '/settings', path: '/settings',
name: Routes.settings, name: Routes.settings,
builder: (context, state) => const SettingsScreen(), builder: (context, state) => const SettingsScreen(),
routes: [ routes: [
GoRoute( GoRoute(
path: 'themeSettings', path: 'themeSettings', // -> /settings/themeSettings
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(),
),
),
], ],
), ),
// ==========================================
// 4. SCHERMATE PRINCIPALI EXTRA NELLA SHELL
// (Accessibili ad es. dalla dashboard, mantengono la sidebar)
// ==========================================
GoRoute( GoRoute(
path: '/operations', path: '/operations',
name: Routes.operations, name: Routes.operations,
builder: (context, state) => const OperationListScreen(), builder: (context, state) => const OperationListScreen(),
), ),
GoRoute(
path: '/customers',
name: Routes.customers,
builder: (context, state) =>
const CustomersListScreen(), // O come si chiama il tuo widget della lista!
),
GoRoute( GoRoute(
path: '/tickets', path: '/tickets',
name: Routes.tickets, name: Routes.tickets,
@@ -208,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(),
);
},
),
], ],
), ),
@@ -466,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(),
);
},
),
], ],
); );
} }

View File

@@ -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';
} }

View File

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

View File

@@ -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,
); );
} }
} }

View File

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

View File

@@ -1,4 +1,6 @@
import 'dart:async'; import 'dart: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();

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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,6 +74,7 @@ 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: AutofillGroup(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -106,6 +112,10 @@ class _AuthScreenState extends State<AuthScreen> {
icon: Icons.email_outlined, icon: Icons.email_outlined,
controller: _emailController, controller: _emailController,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.email,
AutofillHints.username,
],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
FluxTextField( FluxTextField(
@@ -113,10 +123,35 @@ class _AuthScreenState extends State<AuthScreen> {
icon: Icons.lock_outline, icon: Icons.lock_outline,
isPassword: true, // Magia del FluxTextField! isPassword: true, // Magia del FluxTextField!
controller: _passwordController, controller: _passwordController,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => onSubmitted: (_) =>
_submit(), // Se lo supporti nel tuo widget custom _submit(), // Se lo supporti nel tuo widget custom
), ),
// Link "Dimenticato password?" solo in Login mode
if (state.isLoginMode) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: isLoading
? null
: () => context
.read<AuthCubit>()
.requestPasswordReset(
_emailController.text.trim(),
),
child: Text(
context.l10n.authScreenForgotPassword,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
),
),
),
),
],
const SizedBox(height: 40), const SizedBox(height: 40),
// --- BOTTONE PRINCIPALE --- // --- BOTTONE PRINCIPALE ---
@@ -175,9 +210,10 @@ class _AuthScreenState extends State<AuthScreen> {
if (state.isLoginMode) ...[ if (state.isLoginMode) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
TextButton( TextButton(
onPressed: () => context onPressed: () =>
.read<AuthCubit>() context.read<AuthCubit>().requestPasswordReset(
.requestPasswordReset(_emailController.text.trim()), _emailController.text.trim(),
),
child: Text( child: Text(
context.l10n.authScreenForgotPassword, context.l10n.authScreenForgotPassword,
style: TextStyle( style: TextStyle(
@@ -191,6 +227,7 @@ class _AuthScreenState extends State<AuthScreen> {
), ),
), ),
), ),
),
); );
}, },
), ),

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,197 @@
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_note_list/blocs/dashboard_note_list_cubit.dart';
import 'package:flux/features/notes/models/note_model.dart';
import 'package:go_router/go_router.dart'; // Supponendo tu usi GoRouter per la navigazione
class DashboardNoteListCard extends StatelessWidget {
const DashboardNoteListCard({super.key});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
style: BorderStyle.solid,
),
),
child: InkWell(
onTap: () => context.pushNamed(Routes.notes),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Intestazione del riquadro
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.yellow.shade700.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.sticky_note_2_outlined,
size: 16,
color: Colors.yellow.shade700,
),
),
const SizedBox(width: 12),
Text(
'Le mie Note',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
const SizedBox(height: 12),
// Il corpo del widget collegato al Bloc
BlocBuilder<DashboardNoteListCubit, DashboardNoteListState>(
builder: (context, state) {
if (state.status == DashboardNoteListStatus.loading &&
state.notes.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == DashboardNoteListStatus.failure) {
return const Center(
child: Text(
'Errore nel caricamento delle note.',
style: TextStyle(color: Colors.red),
),
);
}
if (state.notes.isEmpty) {
return _buildEmptyState(context);
}
// Prendiamo solo le prime 4 note per non intaccare troppo spazio in Dashboard
final displayNotes = state.notes.take(4).toList();
return SizedBox(
height: 140, // Altezza fissa per lo scroll orizzontale
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: displayNotes.length,
itemBuilder: (context, index) {
return _buildMiniPostIt(context, displayNotes[index]);
},
),
);
},
),
],
),
),
),
);
}
Widget _buildMiniPostIt(BuildContext context, NoteModel note) {
return GestureDetector(
onTap: () {
// Vai al form di dettaglio passando l'ID o l'oggetto
context.pushNamed(
Routes.noteForm,
pathParameters: {'id': note.id!},
extra: note,
);
},
child: Container(
width: 140,
margin: const EdgeInsets.only(right: 12, bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: note.flutterColor,
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 4,
offset: Offset(2, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
note.title?.isNotEmpty == true
? note.title!
: 'Senza titolo',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (note.isPinned)
const Icon(Icons.push_pin, size: 14, color: Colors.black54),
],
),
const SizedBox(height: 8),
Expanded(
child: Text(
note.content ?? '',
style: const TextStyle(fontSize: 12, color: Colors.black87),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.withValues(alpha: 0.3),
style: BorderStyle.solid,
),
),
child: Column(
children: [
const Icon(
Icons.sticky_note_2_outlined,
size: 32,
color: Colors.grey,
),
const SizedBox(height: 8),
const Text('Nessuna nota presente.'),
TextButton(
onPressed: () => context.push('/notes/create'),
child: const Text('Creane una ora'),
),
],
),
);
}
}

View File

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

View File

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

View File

@@ -1,38 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/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(),
),
);
} }
} }
@@ -45,13 +24,13 @@ class _LatestOperationsCardContent extends StatelessWidget {
return Card( return Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)), side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
), ),
child: InkWell( child: InkWell(
onTap: () => context.pushNamed(Routes.operations), onTap: () => context.pushNamed(Routes.operations),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(12.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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();
}
}

View File

@@ -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];
}

View File

@@ -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,
child: Align(
alignment: Alignment.centerRight,
child: Text(
dueDateString,
style: TextStyle( style: TextStyle(
color: context.secondaryText, color: isOverdue
? theme.colorScheme.error
: context.secondaryText,
fontSize: 12, fontSize: 12,
fontWeight: isOverdue
? FontWeight.bold
: FontWeight.normal,
),
),
), ),
), ),
], ],

View File

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

View File

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

View File

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

View File

@@ -1,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
}
}

View File

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

View File

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

View File

@@ -1,23 +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/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);
@@ -65,27 +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,
), ),
_buildDashboardWidget( DashboardNoteListCard(),
title: context.l10n.commonStickyNotes, DashboardTaskListCard(),
icon: Icons.sticky_note_2_outlined,
color: Colors.yellow.shade700,
context: context,
onTap: () => context.pushNamed(Routes.notes),
),
_buildDashboardWidget(
title: context.l10n.homeMyTasks,
icon: Icons.check_box_outlined,
color: Colors.green,
context: context,
),
]), ]),
), ),
), ),
@@ -102,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;
@@ -241,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'});
}, },
), ),
], ],

View File

@@ -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,
), ),
), ),
); );

View File

@@ -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,

View File

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

View File

@@ -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(),
);
},
),
],
);
}
} }

View File

@@ -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()));

View File

@@ -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, {
String? storeId,
}) async {
try {
var filterBuilder = _supabase
.from(Tables.staffMembers) .from(Tables.staffMembers)
.select() .select('''
.eq('company_id', companyId) *,
.order('name', ascending: true); 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

View File

@@ -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,
]; ];
} }

View File

@@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/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) ...[
const SizedBox(height: 4),
Text(
'Qualifica: ${member.jobTitle!}',
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.w500,
),
),
],
], ],
), ),
trailing: Row( // MODIFICA UX: Menu a tendina per le azioni (Salva spazio e previene overflow)
mainAxisSize: MainAxisSize.min, 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: [ children: [
if (member.jobTitle != null && member.jobTitle!.isNotEmpty) ...[ Icon(
Text('Qualifica: ${member.jobTitle!}'), !member.hasJoined ? Icons.send : Icons.lock_reset,
const SizedBox(width: 8), size: 20,
],
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: () { const SizedBox(width: 12),
// Chiama la funzione di reset password mascherata da invito Text(
context.read<StaffCubit>().resetPasswordOrResendInviteLink( !member.hasJoined
member.email!, ? "Re-invia Invito"
); : "Reset Password",
}, ),
],
),
),
],
) )
else : null,
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!,
);
},
),
],
],
),
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!);
},
), */
], ],
), ),
), ),

View File

@@ -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,
), ),
); );
} }

View File

@@ -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,
]; ];
} }

View File

@@ -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 {

View File

@@ -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,
}; };
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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,10 +67,13 @@ 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: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -90,21 +95,17 @@ class _StoreCardState extends State<StoreCard> {
label: Text( label: Text(
"${widget.store.associatedProviders.length} Providers", "${widget.store.associatedProviders.length} Providers",
), ),
onPressed: () => _manageStoreProviders(widget.store), onPressed: () =>
_manageStoreProviders(widget.store),
), ),
], ],
); );
}, },
), ),
const SizedBox(width: 16),
TextButton.icon(
onPressed: () => _openStoreForm(context, store: widget.store),
icon: const Icon(Icons.edit, size: 18),
label: const Text("Modifica"),
),
], ],
), ),
), ),
),
], ],
), ),
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
part of 'notes_bloc.dart'; part of 'notes_cubit.dart';
enum NotesStatus { initial, loading, success, failure } 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;
} }

View File

@@ -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)

View File

@@ -1,164 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/notes/blocs/notes_bloc.dart';
import 'package:flux/features/notes/models/note_model.dart';
import 'package:go_router/go_router.dart'; // Supponendo tu usi GoRouter per la navigazione
class DashboardNotesWidget extends StatelessWidget {
const DashboardNotesWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Intestazione del riquadro
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Le mie Note',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
TextButton(
onPressed: () {
// Vai alla bacheca completa
context.push('/notes');
},
child: const Text('Vedi tutte'),
),
],
),
const SizedBox(height: 12),
// Il corpo del widget collegato al Bloc
BlocBuilder<NotesBloc, NotesState>(
builder: (context, state) {
if (state.status == NotesStatus.loading && state.notes.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == NotesStatus.failure) {
return const Center(
child: Text(
'Errore nel caricamento delle note.',
style: TextStyle(color: Colors.red),
),
);
}
if (state.notes.isEmpty) {
return _buildEmptyState(context);
}
// Prendiamo solo le prime 4 note per non intaccare troppo spazio in Dashboard
final displayNotes = state.notes.take(4).toList();
return SizedBox(
height: 140, // Altezza fissa per lo scroll orizzontale
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: displayNotes.length,
itemBuilder: (context, index) {
return _buildMiniPostIt(context, displayNotes[index]);
},
),
);
},
),
],
);
}
Widget _buildMiniPostIt(BuildContext context, NoteModel note) {
return GestureDetector(
onTap: () {
// Vai al form di dettaglio passando l'ID o l'oggetto
context.push('/notes/edit/${note.id}');
},
child: Container(
width: 140,
margin: const EdgeInsets.only(right: 12, bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: note.flutterColor,
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 4,
offset: Offset(2, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
note.title?.isNotEmpty == true
? note.title!
: 'Senza titolo',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (note.isPinned)
const Icon(Icons.push_pin, size: 14, color: Colors.black54),
],
),
const SizedBox(height: 8),
Expanded(
child: Text(
note.content ?? '',
style: const TextStyle(fontSize: 12, color: Colors.black87),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.withValues(alpha: 0.3),
style: BorderStyle.solid,
),
),
child: Column(
children: [
const Icon(
Icons.sticky_note_2_outlined,
size: 32,
color: Colors.grey,
),
const SizedBox(height: 8),
const Text('Nessuna nota presente.'),
TextButton(
onPressed: () => context.push('/notes/create'),
child: const Text('Creane una ora'),
),
],
),
);
}
}

View File

@@ -2,10 +2,8 @@ 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/utils/debouncer.dart'; import 'package:flux/core/utils/debouncer.dart';
import 'package:flux/core/widgets/shared_forms/attachments_section.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:flux/features/master_data/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 {
@@ -22,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;
@@ -45,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;
@@ -92,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"
@@ -105,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);
} }
@@ -136,7 +134,9 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
builder: (context) { builder: (context) {
return StatefulBuilder( return StatefulBuilder(
builder: (context, setModalState) { builder: (context, setModalState) {
return Column( return Material(
color: Colors.transparent,
child: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -203,6 +203,7 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
), ),
), ),
], ],
),
); );
}, },
); );
@@ -250,12 +251,17 @@ 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, // 2. Adattiamo la dimensione dei cerchi
final double circleSize = isNarrow ? 32.0 : 40.0;
// -- PREPARIAMO IL BLOCCO COLORI --
final colorPalette = SizedBox(
height: circleSize,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: _noteColors.length, itemCount: _noteColors.length,
@@ -276,7 +282,7 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
}, },
child: Container( child: Container(
margin: const EdgeInsets.only(right: 12), margin: const EdgeInsets.only(right: 12),
width: 40, width: circleSize,
decoration: BoxDecoration( decoration: BoxDecoration(
color: c, color: c,
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -288,21 +294,22 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
), ),
), ),
child: isSelected child: isSelected
? const Icon( ? Icon(
Icons.check, Icons.check,
color: Colors.black54, color: Colors.black54,
size: 20, size: isNarrow ? 16 : 20,
) )
: null, : null,
), ),
); );
}, },
), ),
), );
),
const SizedBox(width: 16),
// Azioni spostate dentro la nota! // -- PREPARIAMO IL BLOCCO AZIONI --
final actionButtons = Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton( IconButton(
icon: const Icon( icon: const Icon(
Icons.delete_outline, Icons.delete_outline,
@@ -313,7 +320,9 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
), ),
IconButton( IconButton(
icon: Icon( icon: Icon(
_isPinned ? Icons.push_pin : Icons.push_pin_outlined, _isPinned
? Icons.push_pin
: Icons.push_pin_outlined,
color: Colors.black87, color: Colors.black87,
), ),
tooltip: _isPinned tooltip: _isPinned
@@ -333,6 +342,30 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
onPressed: _exportNote, onPressed: _exportNote,
), ),
], ],
);
// 3. DECIDIAMO IL LAYOUT FINALE IN BASE ALLO SPAZIO REALE
if (isNarrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
actionButtons,
const SizedBox(height: 8),
colorPalette,
],
);
}
// Layout "Largo" (Finestra intera)
return Row(
children: [
Expanded(child: colorPalette),
const SizedBox(width: 16),
actionButtons,
],
);
},
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -373,7 +406,9 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
// --- CONDIVISIONE --- // --- CONDIVISIONE ---
SwitchListTile( Material(
color: Colors.transparent,
child: SwitchListTile(
title: const Text( title: const Text(
'Condividi con tutti', 'Condividi con tutti',
style: TextStyle( style: TextStyle(
@@ -382,7 +417,7 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
), ),
), ),
value: _isSharedAll, value: _isSharedAll,
activeColor: Colors.black87, activeThumbColor: Colors.black87,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
onChanged: (val) { onChanged: (val) {
setState(() { setState(() {
@@ -392,6 +427,7 @@ class _NoteFormScreenState extends State<NoteFormScreen> {
_triggerAutoSave(); _triggerAutoSave();
}, },
), ),
),
if (!_isSharedAll) ...[ if (!_isSharedAll) ...[
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -2,16 +2,54 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_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());

View File

@@ -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,
), ),
), ),
); );
@@ -211,7 +219,7 @@ class OperationFormCubit extends Cubit<OperationFormState> {
String? type, String? type,
String? providerId, String? providerId,
String? providerDisplayName, String? providerDisplayName,
String? subtype, String? subType,
String? description, String? description,
DateTime? expirationDate, DateTime? expirationDate,
int? quantity, int? quantity,
@@ -224,7 +232,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,
@@ -249,7 +257,7 @@ class OperationFormCubit extends Cubit<OperationFormState> {
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),
@@ -278,27 +286,126 @@ 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)
final updatedOp = state.operation.copyWith(type: newType, subType: '');
// 2. Prepariamoci ad auto-selezionare il provider
String? newProviderId = updatedOp.providerId;
String? newProviderName = updatedOp.providerDisplayName;
// 3. LA LOGICA DI DEFAULT
if (defaultProviderId != null) {
// Troviamo il provider di default nella lista
final defaultProvider = allProviders
.where((p) => p.id == defaultProviderId)
.firstOrNull;
if (defaultProvider != null) {
// Usiamo l'extension appena creata!
if (defaultProvider.supportsOperation(newType)) {
newProviderId = defaultProvider.id;
newProviderName = defaultProvider.name;
} else {
// Se cambi tipo (es. da Mobile a Luce) e il default non lo supporta, sbianchiamo
newProviderId = null;
newProviderName = null;
}
}
}
// Emettiamo il nuovo stato
emit(
state.copyWith(
operation: updatedOp.copyWith(
providerId: newProviderId,
providerDisplayName: newProviderName,
),
),
);
}
void setTypeWithSmartDefaults({
required String newType,
required List<ProviderModel> allProviders,
String? defaultProviderId,
}) {
final currentOp = state.operation;
// -----------------------------------------
// 1. SMART DATES: Calcolo Scadenze Default
// -----------------------------------------
DateTime? defaultDate; 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
expirationDate: defaultDate, // -----------------------------------------
clearProvider: true, String? newProviderId = currentOp.providerId;
clearSubtype: true, String? newProviderName = currentOp.providerDisplayName;
clearModel: true,
clearQuantity: true, // A) Il provider attuale è ancora compatibile col nuovo tipo scelto?
if (newProviderId != null && newProviderId.isNotEmpty) {
final currentProvider = allProviders
.where((p) => p.id == newProviderId)
.firstOrNull;
if (currentProvider == null ||
!currentProvider.supportsOperation(newType)) {
// Non è più compatibile (es. da TIM fisso passo a Energy). Lo sbianchiamo!
newProviderId = null;
newProviderName = null;
}
}
// B) Se non c'è un provider selezionato, proviamo ad auto-inserire quello di default del negozio
if ((newProviderId == null || newProviderId.isEmpty) &&
defaultProviderId != null) {
final defaultProvider = allProviders
.where((p) => p.id == defaultProviderId)
.firstOrNull;
// Controlliamo che il default del negozio supporti questa specifica operazione
if (defaultProvider != null &&
defaultProvider.supportsOperation(newType)) {
newProviderId = defaultProvider.id;
newProviderName = defaultProvider.name;
}
}
// -----------------------------------------
// 3. EMISSIONE DELLO STATO PULITO
// -----------------------------------------
emit(
state.copyWith(
operation: currentOp.copyWith(
type: newType,
subType:
'', // Resettiamo il sottotipo per evitare incongruenze (es. passo da Luce a DAZN)
expirationDate:
defaultDate, // Impostiamo la scadenza di default se calcolata
providerId: newProviderId,
providerDisplayName: newProviderName,
modelId: null,
modelDisplayName: null,
),
),
); );
} }
} }

View File

@@ -38,6 +38,9 @@ class OperationsRepository {
// --- RECUPERO PAGINATO CON FILTRI E JOIN --- // --- RECUPERO PAGINATO CON FILTRI E JOIN ---
Future<List<OperationModel>> fetchOperations({ Future<List<OperationModel>> fetchOperations({
required String companyId, required String companyId,
String? storeId,
String? staffId,
String? providerId,
required int offset, required int offset,
int limit = 50, int limit = 50,
String? searchTerm, String? searchTerm,
@@ -64,6 +67,18 @@ 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 // Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or( query = query.or(
@@ -83,7 +98,7 @@ class OperationsRepository {
} }
} }
Stream<List<OperationModel>> getLatestStoreOperationsStream({ Stream<List<OperationModel>> watchStoreOperations({
required String storeId, required String storeId,
required int limit, required int limit,
}) { }) {

View File

@@ -1,4 +1,5 @@
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';
@@ -27,7 +28,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? providerId;
final String? providerDisplayName; final String? providerDisplayName;
final String? modelId; final String? modelId;
@@ -57,7 +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.providerId,
this.providerDisplayName, this.providerDisplayName,
this.modelId, this.modelId,
@@ -86,7 +87,7 @@ class OperationModel extends Equatable {
String? id, String? id,
DateTime? createdAt, DateTime? createdAt,
String? type, String? type,
String? subtype, String? subType,
String? providerId, String? providerId,
String? providerDisplayName, String? providerDisplayName,
String? modelId, String? modelId,
@@ -113,7 +114,7 @@ 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, providerId: providerId ?? this.providerId,
providerDisplayName: providerDisplayName ?? this.providerDisplayName, providerDisplayName: providerDisplayName ?? this.providerDisplayName,
modelId: modelId ?? this.modelId, modelId: modelId ?? this.modelId,
@@ -143,7 +144,7 @@ class OperationModel extends Equatable {
id, id,
createdAt, createdAt,
type, type,
subtype, subType,
providerId, providerId,
providerDisplayName, providerDisplayName,
modelId, modelId,
@@ -179,15 +180,16 @@ 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?, providerId: map['provider_id'] as String?,
// MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti // MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti
providerDisplayName: (map['provider']?['name'] as String?)?.myFormat(), providerDisplayName: (map[Tables.providers]?['name'] as String?)
?.myFormat(),
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 +204,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,7 +237,7 @@ 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': providerId,
'model_id': modelId, 'model_id': modelId,
'description': description, 'description': description,

View File

@@ -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';
@@ -77,7 +79,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 +91,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,
); );
} }
@@ -398,8 +400,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,7 +483,9 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
icon: Icons.design_services, icon: Icons.design_services,
themeColor: Colors.deepOrange, themeColor: Colors.deepOrange,
children: [ children: [
Row( SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [ children: [
ChoiceChip( ChoiceChip(
label: const Text('Privato (Domestico)'), label: const Text('Privato (Domestico)'),
@@ -514,6 +516,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
), ),
], ],
), ),
),
const Divider(height: 32), const Divider(height: 32),
Wrap( Wrap(
spacing: 8.0, spacing: 8.0,
@@ -524,8 +527,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,
); );
} }
}, },

View File

@@ -1,12 +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/routes/routes.dart'; import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/widgets/staff_selector_modal.dart';
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});
@@ -18,10 +15,13 @@ class OperationListScreen extends StatefulWidget {
class _OperationListScreenState extends State<OperationListScreen> { class _OperationListScreenState extends State<OperationListScreen> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
// 🥷 1. LO STATO PER LE BULK ACTIONS
final Set<String> _selectedOperationIds = {};
bool get _isSelectionMode => _selectedOperationIds.isNotEmpty;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Agganciamo il listener per la paginazione (Scroll Infinito)
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
} }
@@ -35,7 +35,6 @@ 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);
} }
@@ -45,99 +44,265 @@ class _OperationListScreenState extends State<OperationListScreen> {
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) {
return Scaffold( return Scaffold(
appBar: AppBar( // 🥷 2. APPBAR DINAMICA (Standard o Modalità Selezione)
appBar: _isSelectionMode
? AppBar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _clearSelection,
),
title: Text("${_selectedOperationIds.length} selezionate"),
actions: [
IconButton(
icon: const Icon(Icons.edit_note),
tooltip: 'Cambia Stato Massivo',
onPressed: () {
// TODO: Apri BottomSheet per cambiare stato a tutte le selezionate
},
),
],
)
: AppBar(
title: const Text("Gestione Servizi"), title: const Text("Gestione Servizi"),
elevation: 0, elevation: 0,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.search), icon: const Icon(Icons.filter_list),
onPressed: () { onPressed: () {
// Qui potrai implementare una barra di ricerca // TODO: Apri drawer laterale o modal per i filtri avanzati
}, },
), ),
IconButton(icon: const Icon(Icons.search), onPressed: () {}),
], ],
), ),
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 const Center(child: Text("Nessuna pratica trovata."));
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Nessuna pratica trovata."),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () => context
.read<OperationListCubit>()
.loadOperations(refresh: true),
child: const Text("Riprova"),
),
],
),
);
} }
// 3. La Lista (con Pull-to-refresh) // 🥷 3. IL MOTORE RESPONSIVO
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => context.read<OperationListCubit>().loadOperations( onRefresh: () => context.read<OperationListCubit>().loadOperations(
refresh: true, refresh: true,
), ),
child: ListView.builder( child: LayoutBuilder(
builder: (context, constraints) {
// Se lo schermo è largo (Desktop/Tablet), usiamo la griglia
final isDesktop = constraints.maxWidth > 700;
return GridView.builder(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB padding: const EdgeInsets.all(12).copyWith(bottom: 80),
// Magia della griglia: si adatta!
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent:
450, // Larghezza massima della singola card
mainAxisExtent:
180, // Altezza fissa della card (da aggiustare in base ai tuoi font)
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: state.hasReachedMax itemCount: state.hasReachedMax
? state.operations.length ? state.operations.length
: state.operations.length + 1, : state.operations.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= state.operations.length) { if (index >= state.operations.length) {
return const Center( return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
),
); );
} }
final operation = state.operations[index]; final operation = state.operations[index];
return _buildOperationCard(context, operation); final isSelected = _selectedOperationIds.contains(
}, operation.id,
),
); );
},
), return _RichOperationCard(
floatingActionButton: FloatingActionButton( operation: operation,
onPressed: () async { isSelected: isSelected,
StaffMemberModel? createdBy = await getStaffMember(context); isSelectionMode: _isSelectionMode,
if (createdBy == null || !context.mounted) return; 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!},
); );
}
},
onLongPress: () => _toggleSelection(operation.id!),
);
},
);
},
),
);
},
),
floatingActionButton: _isSelectionMode
? null // Nascondi il FAB se stai selezionando
: FloatingActionButton(
onPressed: () {
/* Tuo codice per nuova operazione */
}, },
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
); );
} }
}
// 🥷 4. LA SUPER CARD ESTRATTA
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,
});
// 🥷 1. IL COLORE DELLO STATO: Centralizzato per usarlo ovunque
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; // O Colors.red se preferisci
}
}
// 🥷 2. IL COLORE DEL TIPO: Per farlo risaltare
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);
Widget _buildOperationCard(BuildContext context, OperationModel operation) {
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), elevation: isSelected ? 4 : 1,
elevation: 2, shape: RoundedRectangleBorder(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), borderRadius: BorderRadius.circular(12),
child: ListTile( side: BorderSide(
contentPadding: const EdgeInsets.all(12), color: isSelected ? theme.colorScheme.primary : Colors.transparent,
title: Row( width: 2,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
onLongPress: onLongPress,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primaryContainer.withValues(alpha: 0.2)
: null,
// BANDA LATERALE LEGATA ALLO STATO (Stilosissima)
border: Border(left: BorderSide(color: statusColor, width: 6)),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER ---
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (isSelectionMode)
SizedBox(
height: 24,
width: 24,
child: Checkbox(
value: isSelected,
onChanged: (_) => onTap(),
),
),
Expanded(
child: Text(
operation.reference.isEmpty
? 'Nessuna Riferimento'
: operation.reference,
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
"${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),
// --- CLIENTE E TIPO OPERAZIONE ---
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
@@ -146,61 +311,183 @@ class _OperationListScreenState extends State<OperationListScreen> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 16,
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
// IL TIPO DI OPERAZIONE CHE SPICCA
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: typeColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: typeColor.withValues(alpha: 0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_getIconForType(
operation.type,
operation.subType,
) !=
null) ...[
Icon(
_getIconForType(
operation.type,
operation.subType,
),
size: 14,
color: typeColor,
),
const SizedBox(width: 4),
],
Text(
operation.subType?.isNotEmpty == true
? operation.subType!
: operation.type,
style: TextStyle(
color: typeColor,
fontWeight: FontWeight.bold,
fontSize: 12,
), ),
), ),
], ],
), ),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
"Pratica: ${operation.reference}${operation.createdAt?.day}/${operation.createdAt?.month}/${operation.createdAt?.year}",
), ),
const SizedBox(height: 8), ],
),
const SizedBox(height: 12),
// --- I TAG COMPATTI (Business/Privato, Provider, Device) ---
Wrap(
spacing: 6,
runSpacing: 6,
children: [
// Espanso in "Business" e "Privato"
_MiniChip(
label: operation.isBusiness ? 'Business' : 'Privato',
icon: operation.isBusiness
? Icons.business
: Icons.person,
color: operation.isBusiness ? Colors.indigo : Colors.teal,
),
// Tag Provider con il suo colore personalizzato dal DB
if (operation.providerId != null)
_MiniChip(
label: operation.providerDisplayName ?? 'Gestore',
// Se hai popolato il campo colorHex, qui puoi usare: operation.provider?.displayColor ?? Colors.grey
color: Colors.redAccent,
),
if (operation.type == 'Fin' && operation.modelId != null)
_MiniChip(
label: operation.modelDisplayName ?? 'Modello',
icon: Icons.devices,
color: Colors.deepPurple,
),
],
),
const Spacer(),
// --- FOOTER: Staff e Stato ---
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row( Row(
children: [ children: [
Text(operation.type), const Icon(
const SizedBox(width: 8), Icons.support_agent,
_buildOperationStatus(operation.status), size: 14,
color: Colors.grey,
),
const SizedBox(width: 4),
Text(
operation.staffDisplayName ?? 'Staff',
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.grey[700],
),
),
],
),
_buildOperationStatus(operation.status, statusColor),
], ],
), ),
], ],
), ),
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,
);
} }
void startNewOperation(BuildContext context) { Widget _buildOperationStatus(OperationStatus status, Color statusColor) {
context.pushNamed('operation-form', pathParameters: {'id': 'new'}); return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(8),
),
child: Text(
status.displayName,
style: const TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
);
}
}
class _MiniChip extends StatelessWidget {
final String label;
final IconData? icon;
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: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
border: Border.all(color: color.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 12, color: color),
const SizedBox(width: 4),
],
Text(
label,
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
);
} }
} }

View File

@@ -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) {
@@ -198,8 +164,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 +174,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);
} }
}, },
), ),

View File

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

View File

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

View File

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

View File

@@ -8,14 +8,31 @@ 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(
builder: ((context, constraints) {
final isLargeScreen = constraints.maxWidth >= 600;
return _buildMainContent(
state: state,
isLargeScreen: isLargeScreen,
context: context,
);
}),
);
},
);
}
Widget _buildMainContent({
required BuildContext context,
required DocumentSequenceState state,
required bool isLargeScreen,
}) {
final year = DateTime.now().year;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -23,6 +40,7 @@ class DocumentSequenceSection extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 16.0), padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Text( child: Text(
"Protocolli e Numerazione", "Protocolli e Numerazione",
style: Theme.of( style: Theme.of(
context, context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
@@ -74,10 +92,7 @@ class DocumentSequenceSection extends StatelessWidget {
), ),
onChanged: (val) => context onChanged: (val) => context
.read<DocumentSequenceCubit>() .read<DocumentSequenceCubit>()
.updateLocalSequence( .updateLocalSequence(docType.name, prefix: val),
docType.name,
prefix: val,
),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -103,9 +118,7 @@ class DocumentSequenceSection extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors color: Theme.of(context).colorScheme.surfaceContainer,
.grey
.shade100, // Se hai un tema scuro potresti voler usare Theme.of(context).colorScheme.surfaceContainer
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
@@ -119,19 +132,20 @@ class DocumentSequenceSection extends StatelessWidget {
Text( Text(
"Anteprima prossimo: ", "Anteprima prossimo: ",
style: TextStyle( style: TextStyle(
color: Colors color:
.grey Colors.grey.shade700, // Idem per la dark mode
.shade700, // Idem per la dark mode
fontSize: 12, fontSize: 12,
), ),
), ),
Text( Flexible(
child: Text(
preview, preview,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontFamily: 'monospace', fontFamily: 'monospace',
), ),
), ),
),
], ],
), ),
), ),
@@ -152,7 +166,5 @@ class DocumentSequenceSection extends StatelessWidget {
), ),
], ],
); );
},
);
} }
} }

View File

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

View File

@@ -5,6 +5,7 @@ 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/settings/blocs/settings_cubit.dart'; import 'package:flux/features/settings/blocs/settings_cubit.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
@@ -17,20 +18,64 @@ class SettingsScreen extends StatelessWidget {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
_settingsSection('Account', [ _settingsSection('Utente', [
_settingsTile( _settingsTile(
icon: Icons.person, title: 'Impostazioni Promemoria',
title: 'Profilo Utente', icon: Icons.notifications,
subtitle: 'Configura i tuoi dati', subtitle: 'Notifiche predefinite',
context: context, context: context,
onTap: () {}, onTap: () => context.pushNamed(Routes.reminderSettings),
),
]),
_settingsSection('Azienda', [
_settingsTile(
title: 'Impostazioni Azienda',
icon: Icons.business,
subtitle: 'Configura i dati aziendali',
context: context,
onTap: () => context.pushNamed(Routes.companySettings),
), ),
const Divider(height: 30), const Divider(height: 30),
_settingsTile(
title: 'Impostazione Negozi',
icon: Icons.store,
subtitle: 'Crea o configura i negozi',
context: context,
onTap: () => context.pushNamed(Routes.stores),
),
const Divider(height: 30),
_settingsTile(
title: 'Impostazione Staff / Utenti',
icon: Icons.group,
subtitle:
'Configura i membri dei negozi o invita nuovi utenti in azienda',
context: context,
onTap: () => context.pushNamed(Routes.staff),
),
]),
const SizedBox(height: 20),
_settingsSection('Applicazione', [
BlocBuilder<SettingsCubit, SettingsState>( BlocBuilder<SettingsCubit, SettingsState>(
builder: (context, state) => CheckboxListTile( builder: (context, state) => CheckboxListTile(
value: state.isSingleUserMode, value: state.isSingleUserMode,
title: const Text(
title: Row(
children: [
const Icon(Icons.person, color: FluxColors.primaryBlue),
const SizedBox(width: 12),
Flexible(
child: Text(
'Modalità utente singolo (dispositivo personale)', 'Modalità utente singolo (dispositivo personale)',
style: Theme.of(context).textTheme.titleLarge,
),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(left: 36),
child: Text(
'Utente ${GetIt.I.get<SessionCubit>().state.currentStaffMember?.name ?? 'Nessuno'} selezionato automaticamente',
),
), ),
onChanged: (value) { onChanged: (value) {
context.read<SessionCubit>().setIsSingleUserMode(value!); context.read<SessionCubit>().setIsSingleUserMode(value!);
@@ -39,16 +84,6 @@ class SettingsScreen extends StatelessWidget {
), ),
), ),
const Divider(height: 30), const Divider(height: 30),
_settingsTile(
title: 'Impostazioni Azienda',
icon: Icons.business,
subtitle: 'Configura i dati aziendali',
context: context,
onTap: () => context.pushNamed(Routes.companySettings),
),
]),
const SizedBox(height: 16),
_settingsSection('Applicazione', [
_settingsTile( _settingsTile(
icon: Icons.dark_mode, icon: Icons.dark_mode,
title: 'Tema (FLUX Dark)', title: 'Tema (FLUX Dark)',
@@ -57,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(),

View File

@@ -0,0 +1,257 @@
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: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!;
String get _currentUserId => _sessionCubit.state.currentStaffMember!.id!;
String? get _currentStoreId => _sessionCubit.state.currentStore?.id;
// --- ARMED INITIALIZATION (Nuovo, Esistente o Deep Link) ---
Future<void> initForm({
String? initialTaskId,
TaskModel? existingTask,
}) async {
emit(state.copyWith(status: TaskFormStatus.loading));
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,
),
);
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: _currentUserId,
);
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: _currentUserId,
);
emit(state.copyWith(reminders: existingConfigs));
} catch (e) {
debugPrint('Errore caricamento reminder: $e');
}
}
// --- AGGIORNAMENTO CAMPI ---
void updateTitle(String t) => emit(state.copyWith(title: t));
void updateDescription(String d) => emit(state.copyWith(description: d));
void updateDueDate(DateTime? d) => emit(state.copyWith(dueDate: d));
void toggleStaffSelection(String staffId) {
final updated = List<String>.from(state.selectedStaffIds);
updated.contains(staffId) ? updated.remove(staffId) : updated.add(staffId);
emit(state.copyWith(selectedStaffIds: updated));
}
void toggleStoreSelection(String storeName, bool selectAll) {
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,
createdById: _currentUserId,
title: state.title.trim(),
description: state.description.trim(),
dueDate: state.dueDate,
storeId: state.isGlobal
? null
: _currentStoreId, // Gestione nativa basata sulla tua logica
assignedToIds: state.selectedStaffIds,
);
try {
if (state.id == null) {
await _repository.createTask(
task: taskToSave,
assignedStaffIds: state.selectedStaffIds,
currentUserId: _currentUserId,
currentUserCustomReminders: state.reminders,
);
} else {
await _repository.updateTask(
task: taskToSave,
assignedStaffIds: state.selectedStaffIds,
currentUserId: _currentUserId,
currentUserCustomReminders: state.reminders,
);
}
emit(state.copyWith(status: TaskFormStatus.success));
} catch (e) {
emit(
state.copyWith(
status: TaskFormStatus.failure,
errorMessage: e.toString(),
),
);
}
}
}

View File

@@ -0,0 +1,73 @@
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; // <-- RIPRISTINATO
final String? errorMessage;
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,
});
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,
}) {
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,
);
}
@override
List<Object?> get props => [
id,
status,
title,
description,
dueDate,
isGlobal,
selectedStaffIds,
reminders,
groupedAvailableStaff,
errorMessage,
];
}

View 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();
}
}

View 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];
}

View File

@@ -0,0 +1,375 @@
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('task_reminders')
.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: Usa il join esplicito stile "Notes"
var filterBuilder = _supabase
.from(Tables.tasks)
.select('''
*,
task_assignments:${Tables.taskAssignments} (
${Tables.staffMembers} (*)
)
''')
.eq('company_id', companyId);
if (storeId != null) {
filterBuilder = filterBuilder.or(
'store_id.eq.$storeId,store_id.is.null',
);
}
if (staffId != null) {
// Grazie al trigger, hai l'array pronto per il filtro senza impazzire!
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('''
*,
task_assignments:${Tables.taskAssignments} (
${Tables.staffMembers} (*)
)
''')
.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<TaskModel>> watchCompanyTasks(String companyId) {
return _supabase
.from('tasks')
.stream(primaryKey: ['id'])
.eq('company_id', companyId)
.map((listOfMaps) {
return listOfMaps.map((map) => TaskModel.fromMap(map)).toList();
});
}
// =========================================================================
// 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,
},
)
.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('tasks')
.update({
'title': task.title,
'description': task.description,
'due_date': task.dueDate?.toIso8601String(),
'store_id': task.storeId,
'updated_at': DateTime.now().toIso8601String(),
})
.eq('id', taskId);
// 2. Aggiornamento Assegnazioni: eliminiamo le vecchie, inseriamo le nuove
await _supabase.from('task_assignments').delete().eq('task_id', taskId);
if (assignedStaffIds.isNotEmpty) {
final assignmentsToInsert = assignedStaffIds
.map(
(staffId) => {
'task_id': taskId,
'staff_id': staffId,
'company_id': task.companyId,
},
)
.toList();
await _supabase.from('task_assignments').insert(assignmentsToInsert);
}
// Se non c'è una data, eliminiamo tutti i vecchi promemoria dell'utente loggato per pulizia
if (task.dueDate == null) {
await _supabase
.from('task_reminders')
.delete()
.eq('task_id', taskId)
.eq('staff_id', currentUserId)
.eq('is_forced', false);
return;
}
// 3. GESTIONE REMINDER: Puliamo SOLO quelli modificabili dall'utente loggato
await _supabase
.from('task_reminders')
.delete()
.eq('task_id', taskId)
.eq('staff_id', currentUserId)
.eq('is_forced', false); // NON tocchiamo quelli forzati dal manager!
// 4. Inseriamo le nuove configurazioni salvate dal Cubit (solo se è ancora tra gli assegnatari)
if (assignedStaffIds.contains(currentUserId) &&
currentUserCustomReminders.isNotEmpty) {
final List<Map<String, dynamic>> toInsert = [];
for (var config in currentUserCustomReminders) {
final triggerAt = task.dueDate!.subtract(
Duration(minutes: config.minutesBefore),
);
if (triggerAt.isAfter(DateTime.now())) {
toInsert.add(
_buildReminderRow(
task,
taskId,
currentUserId,
config,
triggerAt,
false,
),
);
}
}
if (toInsert.isNotEmpty) {
await _supabase.from('task_reminders').insert(toInsert);
}
}
} catch (e) {
throw Exception('Errore durante l\'aggiornamento del task: $e');
}
}
// --- HELPER PRIVATO PER LA MAPPA DEL REMINDER ---
Map<String, dynamic> _buildReminderRow(
TaskModel task,
String taskId,
String staffId,
TaskReminderConfig config,
DateTime triggerAt,
bool isForced,
) {
return {
'company_id': task.companyId,
'task_id': taskId,
'staff_id': staffId,
'minutes_before': config.minutesBefore,
'channel': config.channel,
'trigger_at': triggerAt.toIso8601String(),
'is_forced': isForced,
'is_sent': false,
};
}
}

View File

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

View File

@@ -0,0 +1,154 @@
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 String? createdById;
final DateTime? dueDate;
final TaskStatus status;
final DateTime? createdAt;
final String? storeId;
const TaskModel({
this.id,
this.companyId,
required this.title,
this.description,
this.assignedToIds = const [],
this.assignedToStaff = const [],
this.createdById,
this.dueDate,
this.status = TaskStatus.open,
this.createdAt,
this.storeId,
});
bool get isGlobal => storeId == null;
// --- FACTORY: MODELLO VUOTO (Per le creazioni) ---
factory TaskModel.empty({String? companyId, String? createdById}) {
return TaskModel(
companyId: companyId,
title: '',
description: '',
assignedToIds: const [],
assignedToStaff: const [],
createdById: createdById,
status: TaskStatus.open,
createdAt: DateTime.now(),
);
}
// --- EQUATABLE: PROPRIETÀ DA COMPARARE ---
@override
List<Object?> get props => [
id,
companyId,
title,
description,
assignedToIds,
assignedToStaff,
createdById,
dueDate,
status,
createdAt,
storeId,
];
// --- COPY WITH ---
TaskModel copyWith({
String? id,
String? companyId,
String? title,
String? description,
List<String>? assignedToIds,
List<StaffMemberModel>? assignedToStaff,
String? createdById,
DateTime? dueDate,
bool clearDueDate = false, // Flag ninja per resettare la scadenza
TaskStatus? status,
DateTime? createdAt,
String? storeId,
bool clearStoreId = false,
}) {
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,
createdById: createdById ?? this.createdById,
dueDate: clearDueDate ? null : (dueDate ?? this.dueDate),
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
storeId: clearStoreId ? null : (storeId ?? this.storeId),
);
}
// --- 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,
createdById: map['created_by_id'] as String?,
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?,
);
}
// --- 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 (createdById != null) 'created_by_id': createdById,
'due_date': dueDate?.toUtc().toIso8601String(),
'status': status.toValue,
'store_id': storeId,
};
}
}

View File

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

View File

@@ -0,0 +1,40 @@
// Enum per lo stato del task
enum TaskStatus { open, inProgress, completed }
extension TaskStatusExtension on TaskStatus {
String get name {
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';
}
}
}

View File

@@ -0,0 +1,583 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/tasks/blocs/task_form_cubit.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: [
if (state.status == TaskFormStatus.submitting)
const Padding(
padding: EdgeInsets.all(16.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
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,
),
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;
}
}

View 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,
),
),
],
),
],
),
),
),
);
},
),
);
}
}

View File

@@ -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',
),
);
}
}
} }

View File

@@ -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;

View File

@@ -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()));
}
}
} }

View File

@@ -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 {

View File

@@ -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,

View File

@@ -141,6 +141,55 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
} }
} }
// Formatta in "GG/MM/AAAA HH:MM"
String _formatDateTime(DateTime dt) {
return "${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}";
}
// Lancia i popup di Data e poi di Ora
Future<void> _selectDeliveryDate(
BuildContext context,
TicketModel ticket,
) async {
final initialDate = ticket.estimatedDeliveryAt ?? DateTime.now();
// 1. Chiediamo la Data
final pickedDate = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: DateTime(
2020,
), // Oppure DateTime.now() se non vuoi date passate
lastDate: DateTime(2100),
);
if (pickedDate == null) return; // L'utente ha annullato
// 2. Chiediamo l'Ora
if (!context.mounted) return;
final pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(initialDate),
);
if (pickedTime == null) return; // L'utente ha annullato
// 3. Fondiamo Data e Ora in un unico DateTime
final finalDateTime = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime.hour,
pickedTime.minute,
);
// 4. Aggiorniamo il Cubit
if (!context.mounted) return;
context.read<TicketFormCubit>().updateFields(
estimatedDeliveryAt: finalDateTime,
);
}
Future<String?> _generateIdForQr() async { 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,32 +418,33 @@ 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 || if (ticket.ticketStatus == TicketStatus.open ||
ticket.ticketStatus == TicketStatus.waitingForParts) { ticket.ticketStatus == TicketStatus.waitingForParts) ...[
return Padding( // CONDIZIONE A: Da iniziare
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(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: backgroundColor: Colors.amber.shade700, // Colore Action
Colors.amber.shade700, // Colore Action
), ),
onPressed: () async { onPressed: () async {
StaffMemberModel? takenBy = await getStaffMember( StaffMemberModel? takenBy = await getStaffMember(context);
context,
);
if (takenBy == null || !context.mounted) return; if (takenBy == null || !context.mounted) return;
context.read<TicketFormCubit>().takeInCharge( context.read<TicketFormCubit>().takeInCharge(
staffId: takenBy.id!, staffId: takenBy.id!,
@@ -378,11 +458,10 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
style: TextStyle(color: Colors.white), style: TextStyle(color: Colors.white),
), ),
), ),
); ),
} ],
// CONDIZIONE B: Già in lavorazione if (ticket.ticketStatus == TicketStatus.inProgress) ...[
else if (ticket.ticketStatus == TicketStatus.inProgress) { Padding(
return Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16.0, horizontal: 16.0,
vertical: 8.0, vertical: 8.0,
@@ -392,13 +471,8 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
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,13 +1108,17 @@ 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(
child: Text(
title, title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: themeColor, color: themeColor,
), ),
), ),
),
], ],
), ),
const Divider(height: 32), const Divider(height: 32),

View File

@@ -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),

View File

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

View File

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

89
lib/firebase_options.dart Normal file
View File

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

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