151 Commits

Author SHA1 Message Date
f8504d466a fix attachments
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m44s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m5s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 8m7s
2026-06-08 14:36:31 +02:00
22bb86f052 Merge branch 'main' of ssh.catelli.it:brontomark/flux 2026-06-08 13:20:52 +02:00
fc850795c9 fix attachments with no parentId 2026-06-08 13:17:33 +02:00
b60ce96dd7 fix uploads
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m14s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m3s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 8m48s
2026-06-08 13:15:29 +02:00
6582da60d4 edge function per interrogare openai, capire quali immagini sono di documenti d'identità e assegnarli al cliente 2026-06-05 07:50:07 +02:00
d42cc5af1d v check fix
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m30s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m6s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 6m20s
2026-06-04 15:21:11 +02:00
7ea0e2ac10 changed version check
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m33s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m4s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 6m14s
2026-06-04 14:21:27 +02:00
5ce0110197 fix operations e tasks
Some checks failed
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 6m20s
Build and Release FLUX (Multi-Platform) / build-android (push) Failing after 11m52s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m0s
2026-06-04 13:42:29 +02:00
4efc3ce182 mmmh
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m11s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m1s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 8m5s
2026-06-04 12:34:38 +02:00
01515910b6 mah....volare 2026-06-03 19:16:15 +02:00
f27ede7625 v - changed operation list
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 2m21s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m9s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 8m4s
2026-06-03 13:22:47 +02:00
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
7b072a219d feat notes 2026-05-22 10:12:56 +02:00
23d3356e6b fg 2026-05-21 19:29:46 +02:00
5b2702daed notes 2026-05-21 14:43:47 +02:00
b9c3eb7091 Merge branch 'main' into feat-notes
ho fatto delle modifiche al main necessarie anche di qui
2026-05-20 20:06:34 +02:00
6fbc5d947c df
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 41s
2026-05-20 17:21:11 +02:00
f520a02226 v
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 29s
2026-05-20 16:48:09 +02:00
3a43b2672a v 2026-05-20 16:48:00 +02:00
61959a5a2e v
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 42s
2026-05-20 16:41:23 +02:00
5f16ee2b38 r 2026-05-20 16:38:21 +02:00
a8ebb1dada df 2026-05-20 16:13:35 +02:00
862719b8b0 fd
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 28s
2026-05-20 16:12:32 +02:00
d1ee6d8a10 a
Some checks failed
Build and Release FLUX Windows / build (push) Has been cancelled
2026-05-20 15:58:04 +02:00
c3268012a5 prova auto build
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 26s
2026-05-20 14:24:49 +02:00
da24b6a5ed impostato per auto build & release windows sul pc bancone 2026-05-20 14:20:10 +02:00
8b8dd0a427 i 2026-05-20 12:08:10 +02:00
979ab5e86d a 2026-05-20 11:07:29 +02:00
9703cb5ce8 d 2026-05-20 11:04:02 +02:00
c85f4b086e refactor nomi tabelle 2026-05-20 11:03:33 +02:00
f190ad9353 g 2026-05-19 19:28:24 +02:00
659963beb0 controllo versione 2026-05-19 18:53:24 +02:00
d3b1e52d88 f 2026-05-19 17:35:18 +02:00
3c0880f527 fix SharedAttachmentSection windows 2026-05-19 17:16:51 +02:00
8a1b582f4e fixes 2026-05-19 16:00:40 +02:00
364474471c fix status colors 2026-05-19 13:39:19 +02:00
3ecf617998 j 2026-05-19 12:46:13 +02:00
3f2f55d6c2 auto isBusiness based on customer selected 2026-05-19 12:03:03 +02:00
4e03d52a5d aggiunta scelta business o privato 2026-05-19 11:54:59 +02:00
2bdba523ad upgraded flutter and refactor document sequences 2026-05-19 11:17:28 +02:00
716de36bfa cambiata visualizzazione ultrawide di operation form screen 2026-05-19 10:41:13 +02:00
00d5890a37 rifatta operation form e diverse migliorie generali 2026-05-19 10:32:01 +02:00
ecb161bc07 b 2026-05-18 19:20:38 +02:00
1ee4a3bf45 fix 2026-05-18 12:57:07 +02:00
5e99324201 refactor shipping attachments and changed shipment to shipping for coherence 2026-05-18 12:00:07 +02:00
b06a655bc3 msi 2026-05-18 10:02:07 +02:00
906265a0e3 k 2026-05-18 08:31:39 +02:00
1a21b44bc8 df 2026-05-16 19:34:33 +02:00
a8c9e0f253 g 2026-05-16 16:39:56 +02:00
491a857f61 d 2026-05-16 15:49:46 +02:00
b3f463b688 fix macos pdf 2026-05-16 14:30:23 +02:00
9a5d0e33bd stampa ddt 2026-05-16 11:51:26 +02:00
a166992b04 refined document sequence management 2026-05-16 09:04:18 +02:00
b5ccb0428d fdsg 2026-05-15 19:18:03 +02:00
f4a8314978 f 2026-05-15 13:32:34 +02:00
f19f19a279 refactor providers e basi per spedizioni 2026-05-15 10:12:05 +02:00
ad35f641b3 k 2026-05-14 19:22:02 +02:00
6c892bf580 d 2026-05-14 19:16:13 +02:00
89099c2cfd lavorazione dei ticket 2026-05-14 15:59:46 +02:00
0f9616f19a d 2026-05-14 12:07:05 +02:00
3b3cfb5e43 f 2026-05-13 19:24:25 +02:00
24004a99da fix routing 2026-05-13 18:12:08 +02:00
ab7601a74e icons 2026-05-13 16:35:34 +02:00
f09606e1f7 fix isSingleUserMode inflated in SessionCubit 2026-05-13 15:55:06 +02:00
c610d68b9c added singleUserMode and removed StaffSection from forms 2026-05-13 15:41:35 +02:00
efb82b0d4a uff 2026-05-13 12:41:07 +02:00
216fd85888 a 2026-05-12 12:36:50 +02:00
2aab70aec5 sistemati ticket 2026-05-12 11:14:48 +02:00
57061da20d a 2026-05-11 20:44:17 +02:00
cbc5387097 w 2026-05-11 18:35:53 +02:00
e52dbee835 mmm 2026-05-11 18:19:48 +02:00
1dee51a7cd prova con metodo pdf vecchio programma assistenza 2026-05-11 17:13:57 +02:00
a76180497e boh 2026-05-11 11:44:14 +02:00
5c86483563 ticket labels e ticket receipt 2026-05-10 14:09:57 +02:00
385c3da0a5 named router with constants to prevent silent bugs 2026-05-09 20:42:42 +02:00
5f39d5b1ad change routes with names 2026-05-09 19:32:40 +02:00
1081609530 fix router 2026-05-09 17:30:51 +02:00
901f63841f d 2026-05-09 16:00:40 +02:00
27a262b54a changed image upload screen from mobile upload screen 2026-05-09 16:00:06 +02:00
a81515e4d8 dialog qr si chiude quando upload finito 2026-05-09 11:43:54 +02:00
73c5751677 Refactor QrUploadDialog to integrate BlocListener for attachment state management 2026-05-09 11:30:02 +02:00
0171ee6141 Refactor camera handling to remove image quality setting and streamline processing logic 2026-05-09 11:24:08 +02:00
1ee2758756 Improve camera image processing with overlay and error handling 2026-05-09 11:07:38 +02:00
fbb21dd8a4 f 2026-05-09 11:03:12 +02:00
45d49b38f7 tolto upsert nel upload documenti (cozzava con rls) 2026-05-09 10:54:49 +02:00
91a7663681 f 2026-05-09 10:20:53 +02:00
302bec114f fix 2026-05-09 10:08:29 +02:00
65aa3c7de8 fix mobile upload 2026-05-09 09:50:20 +02:00
c6ef798b22 dfa 2026-05-08 18:51:28 +02:00
42a9506f02 j 2026-05-08 12:28:14 +02:00
9793ba8348 a 2026-05-07 19:29:39 +02:00
fbf18acf05 df 2026-05-07 18:37:25 +02:00
5c1f9c0ebc upload ticket files 2026-05-07 18:08:45 +02:00
4cc1c9d157 df
Some checks failed
Deploy to Cloudflare Pages / build-and-deploy (push) Has been cancelled
2026-05-07 16:32:26 +02:00
7d03d0dea5 feat-tickets (#14)
Some checks failed
Deploy to Cloudflare Pages / build-and-deploy (push) Has been cancelled
Reviewed-on: #14
Co-authored-by: mark-cachy <marco@catelli.it>
Co-committed-by: mark-cachy <marco@catelli.it>
2026-05-07 16:28:01 +02:00
267 changed files with 23657 additions and 5703 deletions

View File

@@ -0,0 +1,115 @@
name: Build and Release FLUX (Multi-Platform)
on:
push:
tags:
- 'v*'
jobs:
# -----------------------------------------------------------------
# JOB 1: WINDOWS (Gira sul PC del collega appena si libera)
# -----------------------------------------------------------------
build-windows:
runs-on: windows-native
steps:
- name: Checkout del codice
uses: actions/checkout@v3
- name: Crea file .env
run: |
Set-Content -Path ".env" -Value "${{ secrets.ENV_FILE_CONTENT }}"
- name: Build Flutter Windows
run: flutter build windows --release
# 1. FIRMA DELL'ESEGUIBILE RAW
- name: Firma Eseguibile Flutter
run: |
# Cerca dinamicamente signtool.exe nell'SDK di Windows per non sbagliare percorso
$Signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1).FullName
# Sostituisci il percorso con dove hai salvato fisicamente il file .pfx sul PC del runner!
& $Signtool sign /f "C:\flux_privato.pfx" /p "${{ secrets.PFX_PASSWORD }}" /fd SHA256 "build\windows\x64\runner\Release\flux.exe"
- name: Build Windows Installer
run: |
$TagVersion = "${{ github.ref_name }}".Substring(1)
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "/DMyAppVersion=$TagVersion" "win_installer.iss"
# 2. FIRMA DELL'INSTALLER GENERATO DA INNO SETUP
- name: Firma Installer
run: |
$Signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1).FullName
& $Signtool sign /f "C:\flux_privato.pfx" /p "${{ secrets.PFX_PASSWORD }}" /fd SHA256 "build\windows\installer\FluxInstaller.exe"
# Nel dubbio usiamo l'action per caricare l'asset
- name: Upload Windows Asset
uses: https://gitea.com/actions/release-action@main
with:
files: "build/windows/installer/FluxInstaller.exe"
api_key: ${{ secrets.MYRELEASE_TOKEN }}
- name: Pulisci Workspace Windows
if: always()
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"

2
.gitignore vendored
View File

@@ -6,7 +6,7 @@
*.env *.env
.DS_Store .DS_Store
.atom/ .atom/
.build/ .build/*
.buildlog/ .buildlog/
.history .history
.svn/ .svn/

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

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

4
.wrangler/cache/pages.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"account_id": "6badf20faeef39fa5c99283f46f07508",
"project_name": "flux"
}

6
.wrangler/cache/wrangler-account.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"account": {
"id": "6badf20faeef39fa5c99283f46f07508",
"name": "Marco@catelli.it's Account"
}
}

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

@@ -2,7 +2,7 @@
<application <application
android:label="flux" android:label="flux"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/launcher_icon">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -24,11 +24,11 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<intent-filter android:label="flux_deep_link"> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="fluxapp" /> <data android:scheme="flux" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://operations.gradle.org/distributions/gradle-8.14-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

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

BIN
assets/icon/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

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

@@ -0,0 +1,34 @@
# flutter pub run flutter_launcher_icons
flutter_launcher_icons:
image_path: "assets/icon/icon.png"
android: "launcher_icon"
image_path_android: "assets/icon/icon.png"
min_sdk_android: 21 # android min sdk min:16, default 21
# adaptive_icon_background: "assets/icon/background.png"
# adaptive_icon_foreground: "assets/icon/foreground.png"
# adaptive_icon_foreground_inset: 16
# adaptive_icon_monochrome: "assets/icon/monochrome.png"
ios: true
image_path_ios: "assets/icon/icon.png"
remove_alpha_ios: true
# image_path_ios_dark_transparent: "assets/icon/icon_dark.png"
# image_path_ios_tinted_grayscale: "assets/icon/icon_tinted.png"
# desaturate_tinted_to_grayscale_ios: true
background_color_ios: "#ffffff"
web:
generate: true
image_path: "assets/icon/icon.png"
background_color: "#FFFFFF"
theme_color: "#000000"
windows:
generate: true
image_path: "assets/icon/icon.png"
icon_size: 256 # min:48, max:256, default: 48
macos:
generate: true
image_path: "assets/icon/icon.png"

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;
}; };
@@ -543,7 +547,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -600,7 +604,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";

View File

@@ -1,122 +1 @@
{ {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

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

View File

@@ -6,6 +6,7 @@ enum SessionStatus {
unauthenticated, unauthenticated,
onboardingRequired, onboardingRequired,
authenticated, authenticated,
error,
} }
/// Definisce lo step esatto dell'onboarding (Paranoia Mode) /// Definisce lo step esatto dell'onboarding (Paranoia Mode)
@@ -25,6 +26,8 @@ class SessionState extends Equatable {
final StaffMemberModel? currentStaffMember; final StaffMemberModel? currentStaffMember;
final OnboardingStep onboardingStep; final OnboardingStep onboardingStep;
final bool isMobileDevice; final bool isMobileDevice;
final bool isSingleUserMode;
final String? errorMessage;
const SessionState({ const SessionState({
this.status = SessionStatus.initial, this.status = SessionStatus.initial,
@@ -34,6 +37,8 @@ class SessionState extends Equatable {
this.currentStaffMember, this.currentStaffMember,
this.onboardingStep = OnboardingStep.none, this.onboardingStep = OnboardingStep.none,
this.isMobileDevice = false, this.isMobileDevice = 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
@@ -45,6 +50,8 @@ class SessionState extends Equatable {
StaffMemberModel? currentStaffMember, StaffMemberModel? currentStaffMember,
OnboardingStep? onboardingStep, OnboardingStep? onboardingStep,
bool? isMobileDevice, bool? isMobileDevice,
bool? isSingleUserMode,
String? errorMessage,
}) { }) {
return SessionState( return SessionState(
status: status ?? this.status, status: status ?? this.status,
@@ -54,6 +61,8 @@ class SessionState extends Equatable {
currentStaffMember: currentStaffMember ?? this.currentStaffMember, currentStaffMember: currentStaffMember ?? this.currentStaffMember,
onboardingStep: onboardingStep ?? this.onboardingStep, onboardingStep: onboardingStep ?? this.onboardingStep,
isMobileDevice: isMobileDevice ?? this.isMobileDevice, isMobileDevice: isMobileDevice ?? this.isMobileDevice,
isSingleUserMode: isSingleUserMode ?? this.isSingleUserMode,
errorMessage: errorMessage ?? this.errorMessage,
); );
} }
@@ -66,6 +75,8 @@ class SessionState extends Equatable {
currentStaffMember, currentStaffMember,
onboardingStep, onboardingStep,
isMobileDevice, isMobileDevice,
isSingleUserMode,
errorMessage,
]; ];
// Helper rapidi per la UI // Helper rapidi per la UI

View File

@@ -1,2 +0,0 @@
const String resetPasswordUrl =
'https://flux-web-invite.marco-6ba.workers.dev/';

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart'; 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/features/company/models/company_model.dart'; import 'package:flux/features/company/models/company_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:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
@@ -15,7 +16,7 @@ class CoreRepository {
Future<CompanyModel?> getCompanyByOwnerId(String userId) async { Future<CompanyModel?> getCompanyByOwnerId(String userId) async {
try { try {
final response = await _supabase final response = await _supabase
.from('company') .from(Tables.companies)
.select() .select()
.eq('user_id', userId) // <-- Assicurati di avere questo campo nel DB! .eq('user_id', userId) // <-- Assicurati di avere questo campo nel DB!
.maybeSingle(); .maybeSingle();
@@ -31,7 +32,7 @@ class CoreRepository {
Future<CompanyModel?> getCompanyById(String companyId) async { Future<CompanyModel?> getCompanyById(String companyId) async {
try { try {
final response = await _supabase final response = await _supabase
.from('company') .from(Tables.companies)
.select() .select()
.eq('id', companyId) .eq('id', companyId)
.maybeSingle(); .maybeSingle();
@@ -46,7 +47,7 @@ class CoreRepository {
Future<List<StoreModel>> getStoresByCompanyId(String companyId) async { Future<List<StoreModel>> getStoresByCompanyId(String companyId) async {
try { try {
final response = await _supabase final response = await _supabase
.from('store') .from(Tables.stores)
.select() .select()
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('is_active', true) // Buona pratica .eq('is_active', true) // Buona pratica
@@ -62,7 +63,7 @@ class CoreRepository {
Future<StaffMemberModel?> getStaffMemberByUserId(String userId) async { Future<StaffMemberModel?> getStaffMemberByUserId(String userId) async {
try { try {
final response = await _supabase final response = await _supabase
.from('staff_member') .from(Tables.staffMembers)
.select() .select()
.eq('user_id', userId) .eq('user_id', userId)
.maybeSingle(); .maybeSingle();
@@ -80,7 +81,7 @@ class CoreRepository {
Future<CompanyModel> createCompany(CompanyModel company) async { Future<CompanyModel> createCompany(CompanyModel company) async {
try { try {
final response = await _supabase final response = await _supabase
.from('company') .from(Tables.companies)
.insert(company.toMap()) .insert(company.toMap())
.select() .select()
.single(); .single();
@@ -94,7 +95,7 @@ class CoreRepository {
Future<StoreModel> createStore(StoreModel store) async { Future<StoreModel> createStore(StoreModel store) async {
try { try {
final response = await _supabase final response = await _supabase
.from('store') .from(Tables.stores)
.insert(store.toMap()) .insert(store.toMap())
.select() .select()
.single(); .single();
@@ -108,12 +109,12 @@ class CoreRepository {
Future<StaffMemberModel> createStaffMember(StaffMemberModel staff) async { Future<StaffMemberModel> createStaffMember(StaffMemberModel staff) async {
try { try {
final response = await _supabase final response = await _supabase
.from('staff_member') .from(Tables.staffMembers)
.insert(staff.toMap()) .insert(staff.toMap())
.select() .select()
.single(); .single();
final StaffMemberModel staffMember = StaffMemberModel.fromMap(response); final StaffMemberModel staffMember = StaffMemberModel.fromMap(response);
await _supabase.from('staff_in_stores').insert({ await _supabase.from(Tables.staffInStores).insert({
'staff_member_id': staffMember.id, 'staff_member_id': staffMember.id,
'store_id': GetIt.I.get<SessionCubit>().state.currentStore!.id, 'store_id': GetIt.I.get<SessionCubit>().state.currentStore!.id,
}); });
@@ -126,7 +127,7 @@ class CoreRepository {
// 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('staff_in_stores').insert({ await _supabase.from(Tables.staffInStores).insert({
'staff_member_id': staffId, 'staff_member_id': staffId,
'store_id': storeId, 'store_id': storeId,
}); });
@@ -136,6 +137,6 @@ class CoreRepository {
String staffId, String staffId,
Map<String, dynamic> data, Map<String, dynamic> data,
) async { ) async {
await _supabase.from('staff_member').update(data).eq('id', staffId); await _supabase.from(Tables.staffMembers).update(data).eq('id', staffId);
} }
} }

View File

@@ -0,0 +1,28 @@
class Tables {
static const String appConfig = 'app_config';
static const String attachments = 'attachments';
static const String brands = 'brands';
static const String campaigns = 'campaigns';
static const String companies = 'companies';
static const String customers = 'customers';
static const String documentSequences = 'document_sequences';
static const String models = 'models';
static const String notes = 'notes';
static const String noteCollaborators = 'note_collaborators';
static const String operations = 'operations';
static const String providerLocations = 'provider_locations';
static const String providers = 'providers';
static const String providersInStores = 'providers_in_stores';
static const String shippingDocuments = 'shipping_documents';
static const String staffInStores = 'staff_in_stores';
static const String staffMembers = 'staff_members';
static const String stores = 'stores';
static const String tasks = 'tasks';
static const String taskAssignments = 'task_assignments';
static const String taskReminders = 'task_reminders';
static const String tickets = 'tickets';
static const String trackings = 'trackings';
}
const String resetPasswordUrl =
'https://flux-web-invite.marco-6ba.workers.dev/';

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,
onDestinationSelected: (index) => _onItemTapped(index, context),
destinations: [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: context.l10n.commonDashboard,
),
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,
),
],
),
); );
} }
} }
class AppMenu extends StatefulWidget {
final String currentPath; // Lo usiamo ancora per capire cosa accendere
final bool isDrawer;
const AppMenu({super.key, required this.currentPath, required this.isDrawer});
@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,
),
),
),
],
],
),
),
// --- 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

@@ -4,62 +4,113 @@ 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/data/core_repository.dart'; import 'package:flux/core/data/core_repository.dart';
import 'package:flux/core/layout/app_shell.dart'; import 'package:flux/core/layout/app_shell.dart';
import 'package:flux/core/utils/extensions.dart'; import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/widgets/set_password_screen.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/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/customers/blocs/customer_files_bloc.dart'; import 'package:flux/features/auth/ui/set_password_screen.dart';
import 'package:flux/features/customers/blocs/customers_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/customers/blocs/customer_form_cubit.dart';
import 'package:flux/features/customers/blocs/customers_list_cubit.dart';
import 'package:flux/features/customers/models/customer_model.dart'; 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_mobile_upload_screen.dart'; import 'package:flux/features/customers/ui/customer_form_screen.dart';
import 'package:flux/features/customers/ui/customers_content.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';
import 'package:flux/features/master_data/products/ui/products_screen.dart'; import 'package:flux/features/master_data/products/ui/products_screen.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart'; import 'package:flux/features/master_data/providers/blocs/provider_form_cubit.dart';
import 'package:flux/features/master_data/providers/ui/providers_master_data_screen.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/ui/provider_form_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/blocs/staff_cubit.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/ui/notes_form_screen.dart';
import 'package:flux/features/notes/ui/notes_list_screen.dart';
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart'; import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
import 'package:flux/features/onboarding/ui/onboarding_screen.dart'; import 'package:flux/features/onboarding/ui/onboarding_screen.dart';
import 'package:flux/features/operations/blocs/operation_files_bloc.dart'; import 'package:flux/features/attachments/blocs/attachments_bloc.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/features/operations/ui/operation_form_screen.dart'; import 'package:flux/features/operations/ui/operation_form_screen.dart';
import 'package:flux/features/operations/ui/operation_mobile_upload_screen.dart'; import 'package:flux/features/operations/ui/operation_list_screen.dart';
import 'package:flux/features/operations/ui/operations_screen.dart'; import 'package:flux/features/settings/blocs/reminder_defaults_cubit.dart';
import 'package:flux/features/settings/ui/reminder_settings_screen.dart';
import 'package:flux/features/settings/ui/settings_screen.dart';
import 'package:flux/features/settings/ui/theme_settings_view.dart';
import 'package:flux/features/tasks/blocs/task_form_cubit.dart';
import 'package:flux/features/tasks/blocs/task_list_cubit.dart';
import 'package:flux/features/tasks/models/task_model.dart';
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/models/ticket_model.dart';
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
import 'package:flux/features/tickets/ui/ticket_list_screen.dart';
import 'package:flux/features/tickets/ui/ticket_workspace/ticket_workspace_screen.dart';
import 'package:flux/features/tracking/blocs/tracking_cubit.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 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
// Nota: Dovrai creare questi placeholder o file per non avere errori di compilazione
// import 'package:flux/features/master_data/master_data_hub_screen.dart';
// import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
// import 'package:flux/features/master_data/store/ui/stores_screen.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';
final isGoingToOnboarding = state.matchedLocation == '/onboarding'; final isGoingToOnboarding = state.matchedLocation == '/onboarding';
final isGoingToSetPassword = state.matchedLocation == '/set-password'; final isGoingToSetPassword = state.matchedLocation == '/set-password';
// 1. LA PASSATOIA VIP (DEVE ESSERE IN CIMA)
// Usiamo state.uri.path perché state.matchedLocation a volte fa i capricci coi deep link iniziali
final isPublicRoute = state.uri.path.startsWith('/upload');
if (isPublicRoute) {
// Ritorna null esplicitamente per dire al router "Rimani qui e non fare altri controlli"
return null;
}
// 2. CONTROLLO INIZIALE
// Se la sessione sta ancora caricando la primissima volta (es. splash screen logico)
if (sessionState.status == SessionStatus.initial) return null; if (sessionState.status == SessionStatus.initial) return null;
// 3. UTENTE NON LOGGATO (Ma ci arriva solo se non è su /upload)
if (sessionState.status == SessionStatus.unauthenticated) { if (sessionState.status == SessionStatus.unauthenticated) {
// Se sta già andando alle uniche altre pagine pubbliche, lascialo andare
if (isGoingToLogin || isGoingToSetPassword) return null; if (isGoingToLogin || isGoingToSetPassword) return null;
// Altrimenti bloccalo e mandalo al login
return '/login'; return '/login';
} }
// 4. UTENTE LOGGATO MA DEVE COMPLETARE L'ONBOARDING
if (sessionState.status == SessionStatus.onboardingRequired) { if (sessionState.status == SessionStatus.onboardingRequired) {
return isGoingToOnboarding ? null : '/onboarding'; return isGoingToOnboarding ? null : '/onboarding';
} }
// 5. UTENTE PERFETTAMENTE LOGGATO E OPERATIVO
if (sessionState.status == SessionStatus.authenticated) { if (sessionState.status == SessionStatus.authenticated) {
// Se per sbaglio cerca di tornare al login o all'onboarding, ributtalo in dashboard
if (isGoingToLogin || isGoingToOnboarding) return '/'; if (isGoingToLogin || isGoingToOnboarding) return '/';
return null; return null;
} }
@@ -70,14 +121,17 @@ class AppRouter {
// --- ROTTE DI SERVIZIO (FUORI DALLA SHELL) --- // --- ROTTE DI SERVIZIO (FUORI DALLA SHELL) ---
GoRoute( GoRoute(
path: '/login', path: '/login',
name: Routes.login,
builder: (context, state) => const AuthScreen(), builder: (context, state) => const AuthScreen(),
), ),
GoRoute( GoRoute(
path: '/set-password', path: '/set-password',
name: Routes.setPassword,
builder: (context, state) => const SetPasswordScreen(), builder: (context, state) => const SetPasswordScreen(),
), ),
GoRoute( GoRoute(
path: '/onboarding', path: '/onboarding',
name: Routes.onboarding,
builder: (context, state) => BlocProvider( builder: (context, state) => BlocProvider(
create: (context) => OnboardingCubit( create: (context) => OnboardingCubit(
GetIt.I.get<SessionCubit>(), GetIt.I.get<SessionCubit>(),
@@ -91,146 +145,467 @@ 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(path: '/', builder: (context, state) => const HomeScreen()), // ==========================================
GoRoute(
path: '/',
name: Routes.home,
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,
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,
builder: (context, state) { builder: (context, state) {
context.read<ProductsCubit>().refreshCubit(); context.read<ProductsCubit>().refreshCubit();
return const ProductsScreen(); return const ProductsScreen();
}, },
), ),
GoRoute( GoRoute(
path: 'staff', // Diventa /master-data/staff path: 'staff', // -> /master-data/staff
name: Routes.staff,
builder: (context, state) => const StaffScreen(), builder: (context, state) => const StaffScreen(),
), ),
GoRoute( GoRoute(
path: 'stores', // Diventa /master-data/stores path:
builder: (context, state) => const StoresScreen(), '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( GoRoute(
path: 'providers', // Diventa /master-data/providers path: 'company-settings', // -> /master-data/company-settings
builder: (context, state) => name: Routes.companySettings,
const ProvidersMasterDataScreen(), builder: (context, state) => BlocProvider(
create: (context) => CompanySettingsCubit(),
child: const CompanySettingsScreen(),
),
), ),
], ],
), ),
// ==========================================
// 3. IMPOSTAZIONI // 3. IMPOSTAZIONI
// ==========================================
GoRoute( GoRoute(
path: '/settings', path: '/settings',
builder: (context, state) => Scaffold( name: Routes.settings,
appBar: AppBar(title: Text(context.l10n.commonSettings)), builder: (context, state) => const SettingsScreen(),
body: Center( routes: [
child: ElevatedButton.icon( GoRoute(
onPressed: () => context.read<SessionCubit>().signOut(), path: 'themeSettings', // -> /settings/themeSettings
icon: const Icon(Icons.logout), name: Routes.themeSettings,
label: const Text("Esci da FLUX"), 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',
builder: (context, state) => const OperationsScreen(), name: Routes.operations,
builder: (context, state) => const OperationListScreen(),
), ),
GoRoute( GoRoute(
path: '/customers', path: '/tickets',
builder: (context, state) => name: Routes.tickets,
const CustomersContent(), // O come si chiama il tuo widget della lista! builder: (context, state) => const TicketListScreen(),
),
GoRoute(
path: '/notes',
name: Routes.notes,
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(),
);
},
), ),
], ],
), ),
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) --- // --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
GoRoute( GoRoute(
path: '/customer/:id', path: '/providers/form',
name: Routes.providerForm,
builder: (context, state) {
// Estraiamo il fornitore (se stiamo modificando e non creando)
final existingProvider = state.extra as ProviderModel?;
return BlocProvider<ProviderFormCubit>(
// Inizializziamo un Cubit NUOVO ogni volta che apriamo il form
create: (context) => ProviderFormCubit(),
child: ProviderFormScreen(existingProvider: existingProvider),
);
},
),
GoRoute(
// Il path sarà es. /tickets/form/123 oppure /tickets/form/new
path: '/tickets/form/:id',
name: Routes.ticketForm,
builder: (context, state) {
// 1. Leggiamo l'ID dall'URL
final String pathId = state.pathParameters['id'] ?? 'new';
// 2. CAST DA NINJA (Aggiungi i punti interrogativi!)
final record =
state.extra
as ({StaffMemberModel? createdBy, TicketModel? ticket})?;
// 3. LOGICA SOBRIA
final String? realTicketId;
if (pathId == 'new') {
realTicketId = null;
} else if (record?.ticket?.id != null) {
// <-- Parentesi TONDE per la condizione, GRAFFE per il blocco!
realTicketId = record!.ticket!.id;
} else {
realTicketId = pathId;
}
if (realTicketId != null) {
context.read<TrackingCubit>().loadTrackings(
realTicketId,
TrackingParentType.ticket,
);
}
context.read<CustomersListCubit>().loadCustomers();
context.read<ProductsCubit>().loadModels();
context.read<ProductsCubit>().loadBrands();
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => AttachmentsBloc(
parentType: AttachmentParentType.ticket,
parentId: realTicketId,
),
),
BlocProvider(
create: (context) => TicketFormCubit(
// Passiamo il creatore e l'eventuale ticket esistente presi dal Record!
createdBy: record?.createdBy,
existingTicket: record?.ticket,
),
),
],
child: TicketFormScreen(
ticketId: realTicketId,
existingTicket: record?.ticket,
),
);
},
),
GoRoute(
path: '/tickets/workspace/:id',
name: Routes.ticketWorkspace,
builder: (context, state) {
// 1. Recuperiamo il Cubit vivo dall'extra
final formCubit = state.extra as TicketFormCubit?;
// 2. Controllo di sicurezza (fondamentale per Flutter Web)
if (formCubit != null) {
return BlocProvider.value(
value: formCubit,
child: const TicketWorkspaceScreen(),
);
} else {
// SCENARIO REFRESH WEB:
// Se l'utente preme F5 del browser mentre è nel banco da lavoro,
// l'extra viene distrutto e diventa null.
// In questo caso, gli diciamo elegantemente che la sessione è persa
// e lo invitiamo a tornare indietro, oppure restituisci direttamente
// un blocco di redirect!
return const Scaffold(
body: Center(
child: Text(
'Sessione di lavoro scaduta. Torna alla lista e riapri il ticket.',
),
),
);
}
},
),
GoRoute(
path: '/upload-success',
name: Routes.uploadSuccess,
builder: (context, state) => const UploadSuccessScreen(),
),
GoRoute(
path: '/customer/details/:id',
name: Routes.customerDetails,
builder: (context, state) { builder: (context, state) {
final customer = state.extra as CustomerModel; final customer = state.extra as CustomerModel;
return BlocProvider( return BlocProvider(
create: (context) => CustomerFilesBloc(customer.id!), create: (context) => AttachmentsBloc(
parentType: AttachmentParentType.customer,
parentId: customer.id,
),
child: CustomerDetailScreen(customer: customer), child: CustomerDetailScreen(customer: customer),
); );
}, },
), ),
GoRoute( GoRoute(
path: '/customer/:id/upload', path: '/customer/form/:id',
name: Routes.customerForm,
builder: (context, state) { builder: (context, state) {
final customerId = state.pathParameters['id']!; final String pathId = state.pathParameters['id'] ?? 'new';
final customerName = state.uri.queryParameters['name'] ?? 'Cliente'; final String? realCustomerId;
return BlocProvider( if (pathId == 'new') {
create: (context) => CustomerFilesBloc(customerId), realCustomerId = null;
child: CustomerMobileUploadScreen( } else {
customerId: customerId, realCustomerId = pathId;
customerName: customerName, }
), final customer = state.extra as CustomerModel?;
);
},
),
GoRoute(
path: '/operation-form',
name: 'operation-form',
builder: (context, state) {
final existingOperation = state.extra as OperationModel?;
final operationId = state.uri.queryParameters['operationId'];
final currentStoreId = GetIt.I
.get<SessionCubit>()
.state
.currentStore!
.id!;
context.read<CustomersCubit>().loadCustomers();
context.read<ProvidersCubit>().loadActiveProvidersForStore(
currentStoreId,
);
context.read<ProductsCubit>().loadModels();
context.read<ProductsCubit>().loadBrands();
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
return BlocProvider( return BlocProvider(
create: (context) => OperationFilesBloc( create: (context) => CustomerFormCubit(
operationId: operationId ?? existingOperation?.id, existingCustomer: customer,
customerId: realCustomerId,
), ),
child: OperationFormScreen( child: CustomerFormScreen(
operationId: operationId ?? existingOperation?.id, customer: customer,
existingOperation: existingOperation, customerId: realCustomerId,
), ),
); );
}, },
), ),
GoRoute( GoRoute(
path: '/operation/:id/upload', path: '/operations/form/:id',
name: Routes.operationForm,
builder: (context, state) { builder: (context, state) {
final operationId = state.pathParameters['id']!; final String pathId = state.pathParameters['id'] ?? 'new';
final operationName =
state.uri.queryParameters['name'] ?? 'Pratica'; final record =
state.extra
as ({
StaffMemberModel? createdBy,
OperationModel? operation,
})?;
final String? realOperationId;
if (pathId == 'new') {
realOperationId = null;
} else if (record?.operation?.id != null) {
realOperationId = record!.operation!.id;
} else {
realOperationId = pathId;
}
final currentStoreId = GetIt.I final currentStoreId = GetIt.I
.get<SessionCubit>() .get<SessionCubit>()
.state .state
.currentStore! .currentStore!
.id!; .id!;
context.read<CustomersCubit>().loadCustomers(); context.read<CustomersListCubit>().loadCustomers();
context.read<ProvidersCubit>().loadActiveProvidersForStore( context.read<ProviderListCubit>().loadProviders(currentStoreId);
currentStoreId,
);
context.read<ProductsCubit>().loadModels(); context.read<ProductsCubit>().loadModels();
context.read<ProductsCubit>().loadBrands(); context.read<ProductsCubit>().loadBrands();
context.read<StaffCubit>().loadStaffForStore(currentStoreId); return MultiBlocProvider(
return BlocProvider( providers: [
create: (context) => OperationFilesBloc(operationId: operationId), BlocProvider(
child: OperationMobileUploadScreen( create: (context) => AttachmentsBloc(
operationId: operationId, parentId: realOperationId,
operationName: operationName, parentType: AttachmentParentType.operation,
),
),
BlocProvider(
create: (context) => OperationFormCubit(
createdBy: record?.createdBy,
existingOperation: record?.operation,
),
),
],
child: OperationFormScreen(
operationId: realOperationId,
existingOperation: record?.operation,
), ),
); );
}, },
), ),
GoRoute(
path: '/upload/:type/:id',
name: Routes.upload,
builder: (context, state) {
final typeString = state.pathParameters['type']!;
final id = state.pathParameters['id']!;
final companyId = state.uri.queryParameters['companyId']!;
// Trasformiamo la stringa dell'URL nel nostro amato Enum!
final parentType = AttachmentParentType.values.firstWhere(
(e) => e.name == typeString,
orElse: () =>
AttachmentParentType.ticket, // Fallback di sicurezza
);
// Creiamo il BLoC "al volo" solo per questa schermata
return MultiBlocProvider(
providers: [
BlocProvider<AttachmentsBloc>(
create: (context) =>
AttachmentsBloc(parentId: id, parentType: parentType),
),
BlocProvider(create: (context) => ImageUploadCubit()),
],
child: ImageUploadScreen(
title: 'Caricamento Rapido',
companyId: companyId,
),
);
},
),
GoRoute(
path: '/notes/edit/:id',
name: Routes.noteForm,
builder: (context, state) {
final id = state.pathParameters['id']!;
final NoteModel note = state.extra as NoteModel;
// Creiamo il BLoC "al volo" solo per questa schermata
return MultiBlocProvider(
providers: [
BlocProvider<AttachmentsBloc>(
create: (context) => AttachmentsBloc(
parentId: id,
parentType: AttachmentParentType.note,
),
),
],
child: NoteFormScreen(note: note),
);
},
),
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

@@ -0,0 +1,30 @@
class Routes {
static const String login = 'login';
static const String setPassword = 'set-password';
static const String onboarding = 'onboarding';
static const String home = 'home';
static const String masterData = 'master-data';
static const String products = 'products';
static const String companySettings = 'company-settings';
static const String staff = 'staff';
static const String stores = 'stores';
static const String providers = 'providers';
static const String providerForm = 'provider-form';
static const String settings = 'settings';
static const String themeSettings = 'themeSettings';
static const String operations = 'operations';
static const String customers = 'customers';
static const String tickets = 'tickets';
static const String ticketForm = 'ticket-form';
static const String operationForm = 'operation-form';
static const String uploadSuccess = 'upload-success';
static const String customerForm = 'customer-form';
static const String customerDetails = 'customer-details';
static const String upload = 'upload';
static const String ticketWorkspace = 'ticket-workspace';
static const String noteForm = 'note-form';
static const String notes = 'notes';
static const String tasks = 'tasks';
static const String taskForm = 'task-form';
static const String reminderSettings = 'reminder-settings';
}

View File

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

View File

@@ -1,6 +1,6 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/core/enums/enums.dart'; import 'package:flux/core/enums_and_consts/enums.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -0,0 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart';
class Debouncer {
final int milliseconds;
Timer? _timer;
Debouncer({required this.milliseconds});
void run(VoidCallback action) {
_timer?.cancel();
_timer = Timer(Duration(milliseconds: milliseconds), action);
}
void dispose() {
_timer?.cancel();
}
}

View File

@@ -0,0 +1,94 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class VersionCheckService {
Future<String?> checkForceUpdate() async {
try {
// 1. Capiamo su che piattaforma sta girando l'app in questo istante
String currentPlatform = _getCurrentPlatform();
// 2. Recuperiamo SOLO la riga corrispondente alla nostra piattaforma
final dbResponse = await Supabase.instance.client
.from('app_config')
.select('min_version, download_url')
.eq('platform', currentPlatform)
.maybeSingle(); // Usiamo maybeSingle così se non c'è la riga non crasha
if (dbResponse == null) {
return null; // Nessuna regola per questa piattaforma
}
String minVersionFromDb = dbResponse['min_version'] as String;
String downloadUrl = dbResponse['download_url'] as String;
// 3. Recuperiamo la versione locale di Flutter
PackageInfo packageInfo = await PackageInfo.fromPlatform();
String localVersionRaw = packageInfo.version;
// 🥷 TRUCCO 1: Pulizia totale dai build number (+37) o tag "v"
String cleanLocal = localVersionRaw
.split('+')
.first
.replaceAll('v', '')
.trim();
String cleanDb = minVersionFromDb
.split('+')
.first
.replaceAll('v', '')
.trim();
// 🥷 TRUCCO 2: Confronto Semantico Reale
if (_isVersionLower(current: cleanLocal, minimum: cleanDb)) {
// Ritorna il link VERO per questa specifica piattaforma preso dal CSV!
return downloadUrl;
}
return null;
} catch (e) {
debugPrint("Errore durante il check versione: $e");
return null;
}
}
// Helper ninja per mappare le piattaforme in base alle stringhe del tuo DB
String _getCurrentPlatform() {
if (kIsWeb) return 'web';
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
if (Platform.isWindows) return 'windows';
if (Platform.isMacOS) return 'macos';
if (Platform.isLinux) return 'linux';
return 'unknown';
}
// Il motore matematico (resta invariato)
bool _isVersionLower({required String current, required String minimum}) {
if (current == minimum) return false;
List<int> currentParts = current
.split('.')
.map((e) => int.tryParse(e) ?? 0)
.toList();
List<int> minParts = minimum
.split('.')
.map((e) => int.tryParse(e) ?? 0)
.toList();
while (currentParts.length < 3) {
currentParts.add(0);
}
while (minParts.length < 3) {
minParts.add(0);
}
if (currentParts[0] != minParts[0]) {
return currentParts[0] < minParts[0];
}
if (currentParts[1] != minParts[1]) {
return currentParts[1] < minParts[1];
}
return currentParts[2] < minParts[2];
}
}

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

@@ -0,0 +1,60 @@
import 'package:equatable/equatable.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
part 'image_upload_state.dart';
class ImageUploadCubit extends Cubit<ImageUploadState> {
ImageUploadCubit() : super(const ImageUploadState());
void setStatus(ImageUploadStatus status) {
emit(state.copyWith(status: status));
}
void setError(String? message) {
emit(
state.copyWith(status: ImageUploadStatus.failure, errorMessage: message),
);
}
void addFiles(List<PlatformFile> files) {
List<PlatformFile> newFiles = List.from(state.stagedFiles);
newFiles.addAll(files);
emit(
state.copyWith(status: ImageUploadStatus.success, stagedFiles: newFiles),
);
}
void removeFile(PlatformFile file) {
List<PlatformFile> newFiles = List.from(state.stagedFiles);
newFiles.remove(file);
emit(
state.copyWith(status: ImageUploadStatus.success, stagedFiles: newFiles),
);
}
Future<void> addPhoto(XFile photo) async {
final List<PlatformFile> files = List.from(state.stagedFiles);
files.add(PlatformFile(name: photo.name, size: 0));
emit(
state.copyWith(
status: ImageUploadStatus.addingPicture,
stagedFiles: files,
),
);
final List<PlatformFile> newFiles = List.from(files);
newFiles.removeLast();
final PlatformFile loadedFile = PlatformFile(
name: photo.name,
size: await photo.length(),
bytes: await photo.readAsBytes(),
path: photo.path,
);
newFiles.add(loadedFile);
emit(
state.copyWith(status: ImageUploadStatus.success, stagedFiles: newFiles),
);
}
}

View File

@@ -0,0 +1,29 @@
part of 'image_upload_cubit.dart';
enum ImageUploadStatus { initial, addingPicture, uploading, success, failure }
class ImageUploadState extends Equatable {
final ImageUploadStatus status;
final String? errorMessage;
final List<PlatformFile> stagedFiles;
const ImageUploadState({
this.status = ImageUploadStatus.initial,
this.errorMessage,
this.stagedFiles = const [],
});
ImageUploadState copyWith({
ImageUploadStatus? status,
String? errorMessage,
List<PlatformFile>? stagedFiles,
}) {
return ImageUploadState(
status: status ?? this.status,
errorMessage: errorMessage,
stagedFiles: stagedFiles ?? this.stagedFiles,
);
}
@override
List<Object?> get props => [status, errorMessage, stagedFiles];
}

View File

@@ -0,0 +1,306 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/image_upload/blocs/image_upload_cubit.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
class ImageUploadScreen extends StatelessWidget {
final String title;
final String companyId;
const ImageUploadScreen({
super.key,
required this.title,
required this.companyId,
});
bool _isImage(String path) {
return ['jpg', 'jpeg', 'png', 'webp'].contains(path.fileExtension());
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ImageUploadCubit, ImageUploadState>(
builder: (context, state) {
return BlocListener<AttachmentsBloc, AttachmentsState>(
listener: (context, attachmentState) {
if (attachmentState.status == AttachmentsStatus.success &&
state.status == ImageUploadStatus.uploading) {
if (Navigator.of(context).canPop()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("File caricati con successo! ✅"),
),
);
Navigator.of(context).pop();
} else {
context.go('/upload-success');
}
}
if (attachmentState.status == AttachmentsStatus.failure) {
context.read<ImageUploadCubit>().setError(attachmentState.error);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Errore: ${state.errorMessage}")),
);
}
},
child: Scaffold(
appBar: AppBar(
title: Text('Upload: $title'),
automaticallyImplyLeading:
state.status != ImageUploadStatus.uploading,
),
body: Stack(
children: [
Column(
children: [
// --- SEZIONE PULSANTI (Fotocamera / Galleria) ---
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed:
state.status == ImageUploadStatus.uploading
? null
: () => _handleCamera(context),
icon: const Icon(Icons.camera_alt),
label: const Text('SCATTA'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
),
),
Expanded(
child: OutlinedButton.icon(
onPressed:
state.status == ImageUploadStatus.uploading
? null
: () => _handleFilePicker(context),
icon: const Icon(Icons.folder),
label: const Text("GALLERIA"),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
),
),
],
),
),
const Divider(),
// --- SEZIONE ANTEPRIME (La GridView Magica) ---
Expanded(
child: state.stagedFiles.isEmpty
? const Center(
child: Text(
"Nessun file selezionato.\nScatta una foto o scegli dalla galleria.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
3, // 3 colonne stile galleria
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: state.stagedFiles.length,
itemBuilder: (context, index) {
final file = state.stagedFiles[index];
final isImg = _isImage(file.name);
if (file.bytes == null) {
return Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: const Center(
child: CircularProgressIndicator(
color: Colors.blue,
),
),
),
);
}
return Stack(
clipBehavior: Clip.none,
children: [
// L'ANTEPRIMA
Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: isImg
? (file.bytes != null
// Se abbiamo i bytes (es. scatto da fotocamera) usiamo quelli (a prova di Web!)
? Image.memory(
file.bytes!,
fit: BoxFit.cover,
)
// Altrimenti andiamo di file fisico
: const Center(
child:
CircularProgressIndicator(
color: Colors.blue,
),
))
: const Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.picture_as_pdf,
color: Colors.red,
size: 36,
),
SizedBox(height: 4),
Text(
"PDF",
style: TextStyle(
fontSize: 10,
fontWeight:
FontWeight.bold,
),
),
],
),
),
),
// IL PULSANTE CESTINO (In alto a destra)
Positioned(
top: -8,
right: -8,
child: GestureDetector(
onTap: () => context
.read<ImageUploadCubit>()
.removeFile(file),
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: 16,
),
),
),
),
],
);
},
),
),
// --- SEZIONE INVIA E CHIUDI ---
SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
// Il pulsante si accende SOLO se ci sono file nel carrello
onPressed:
state.stagedFiles.isEmpty ||
state.status == ImageUploadStatus.uploading
? null
: () => _submitAllFiles(context),
icon: const Icon(Icons.cloud_upload),
label: Text(
"INVIA ${state.stagedFiles.length} FILE E CHIUDI",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimary,
),
),
),
),
),
],
),
],
),
),
);
},
);
}
// --- LOGICA FOTOCAMERA E LIBRERIA ---
Future<void> _handleCamera(BuildContext context) async {
final ImageUploadCubit imageUploadCubit = context.read<ImageUploadCubit>();
final picker = ImagePicker();
final photo = await picker.pickImage(source: ImageSource.camera);
if (photo != null) {
imageUploadCubit.addPhoto(photo);
}
}
Future<void> _handleFilePicker(BuildContext context) async {
final ImageUploadCubit imageUploadCubit = context.read<ImageUploadCubit>();
final result = await FilePicker.pickFiles(
allowMultiple: true,
withData: true,
);
if (result != null) {
imageUploadCubit.addFiles(result.files);
}
}
// --- LOGICA DI INVIO AL BLoC ---
void _submitAllFiles(BuildContext context) {
final ImageUploadCubit imageUploadCubit = context.read<ImageUploadCubit>();
imageUploadCubit.setStatus(ImageUploadStatus.uploading);
// Lanciamo l'evento del nostro nuovo AttachmentsBloc Agnostico!
context.read<AttachmentsBloc>().add(
UploadAttachmentsEvent(
pickedFiles: imageUploadCubit.state.stagedFiles,
companyId: companyId,
),
);
}
}

View File

@@ -1,32 +1,32 @@
import 'dart:io'; import 'dart:io';
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:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
class CustomerMobileUploadScreen extends StatefulWidget { class OldSharedUploadScreen extends StatefulWidget {
final String customerId; final String title;
final String customerName; final String companyId;
const CustomerMobileUploadScreen({ const OldSharedUploadScreen({
super.key, super.key,
required this.customerId, required this.title,
required this.customerName, required this.companyId,
}); });
@override @override
State<CustomerMobileUploadScreen> createState() => State<OldSharedUploadScreen> createState() => _OldSharedUploadScreenState();
_CustomerMobileUploadScreenState();
} }
class _CustomerMobileUploadScreenState class _OldSharedUploadScreenState extends State<OldSharedUploadScreen> {
extends State<CustomerMobileUploadScreen> {
// 1. LA NOSTRA STAGING AREA (Il "Carrello") // 1. LA NOSTRA STAGING AREA (Il "Carrello")
final List<PlatformFile> _stagedFiles = []; final List<PlatformFile> _stagedFiles = [];
// 2. STATO DI CARICAMENTO GLOBALE // 2. STATO DI CARICAMENTO GLOBALE
bool _isUploading = false; bool _isUploading = false;
bool _isProcessingLocal = false;
// Funzione magica per capire se è un'immagine o un PDF dall'estensione // Funzione magica per capire se è un'immagine o un PDF dall'estensione
bool _isImage(String path) { bool _isImage(String path) {
@@ -36,18 +36,25 @@ class _CustomerMobileUploadScreenState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<CustomerFilesBloc, CustomerFilesState>( return BlocListener<AttachmentsBloc, AttachmentsState>(
listener: (context, state) { listener: (context, state) {
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina! // Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
if (state.status == CustomerFilesStatus.success && _isUploading) { if (state.status == AttachmentsStatus.success && _isUploading) {
ScaffoldMessenger.of(context).showSnackBar( // CONTROLLO MAGICO: C'è una pagina dietro di noi?
const SnackBar( if (Navigator.of(context).canPop()) {
content: Text("Tutti i file caricati con successo! ✅"), // Modalità "App Nativa": siamo entrati dal tasto "Aggiungi"
), ScaffoldMessenger.of(context).showSnackBar(
); const SnackBar(content: Text("File caricati con successo! ✅")),
Navigator.of(context).pop(); );
Navigator.of(context).pop();
} else {
// Modalità "Web/QR Code": Navighiamo alla pagina di successo!
// Assicurati di aver importato go_router in questo file
context.go('/upload-success');
}
} }
if (state.status == CustomerFilesStatus.failure) { if (state.status == AttachmentsStatus.failure) {
setState(() => _isUploading = false); setState(() => _isUploading = false);
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
@@ -56,8 +63,8 @@ class _CustomerMobileUploadScreenState
}, },
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("Upload: ${widget.customerName}"), title: Text("Upload: ${widget.title}"),
// Togliamo la freccia indietro se stiamo caricando per evitare disastri // Togliamo la freccia indietro se stiamo caricando per evitare macelli
automaticallyImplyLeading: !_isUploading, automaticallyImplyLeading: !_isUploading,
), ),
body: Stack( body: Stack(
@@ -110,8 +117,7 @@ class _CustomerMobileUploadScreenState
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
gridDelegate: gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount( const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount: 3, // 3 colonne stile galleria
3, // 3 colonne come la galleria dell'iPhone
crossAxisSpacing: 12, crossAxisSpacing: 12,
mainAxisSpacing: 12, mainAxisSpacing: 12,
), ),
@@ -137,10 +143,17 @@ class _CustomerMobileUploadScreenState
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: isImg child: isImg
? Image.file( ? (file.bytes != null
File(file.path!), // Se abbiamo i bytes (es. scatto da fotocamera) usiamo quelli (a prova di Web!)
fit: BoxFit.cover, ? Image.memory(
) file.bytes!,
fit: BoxFit.cover,
)
// Altrimenti andiamo di file fisico
: Image.file(
File(file.path!),
fit: BoxFit.cover,
))
: const Column( : const Column(
mainAxisAlignment: mainAxisAlignment:
MainAxisAlignment.center, MainAxisAlignment.center,
@@ -228,11 +241,11 @@ class _CustomerMobileUploadScreenState
], ],
), ),
// --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) --- // --- OVERLAY DI CARICAMENTO ---
if (_isUploading) if (_isUploading || _isProcessingLocal)
Container( Container(
color: Colors.black.withValues(alpha: 0.5), color: Colors.black.withValues(alpha: 0.5),
child: const Center( child: Center(
child: Card( child: Card(
child: Padding( child: Padding(
padding: EdgeInsets.all(24.0), padding: EdgeInsets.all(24.0),
@@ -242,7 +255,9 @@ class _CustomerMobileUploadScreenState
CircularProgressIndicator(), CircularProgressIndicator(),
SizedBox(height: 16), SizedBox(height: 16),
Text( Text(
"Caricamento in corso...", _isUploading
? "Invio in corso..."
: "Elaborazione foto...",
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
], ],
@@ -259,30 +274,38 @@ class _CustomerMobileUploadScreenState
// --- LOGICA FOTOCAMERA E LIBRERIA --- // --- LOGICA FOTOCAMERA E LIBRERIA ---
Future<void> _handleCamera() async { Future<void> _handleCamera() async {
final picker = ImagePicker(); setState(() => _isProcessingLocal = true);
final photo = await picker.pickImage( await Future.delayed(const Duration(milliseconds: 100));
source: ImageSource.camera,
imageQuality: 80,
);
if (photo != null) {
final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web!
final photoSize = await photo.length();
final platformFile = PlatformFile( try {
name: photo.name, final picker = ImagePicker();
size: photoSize, final photo = await picker.pickImage(source: ImageSource.camera);
path: photo.path,
bytes: photoBytes, // I bytes ci salvano la vita su Supabase! if (photo != null) {
); final photoBytes = await photo.readAsBytes();
setState(() { final photoSize = await photo.length();
_stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File
}); final platformFile = PlatformFile(
name: photo.name,
size: photoSize,
path: photo.path,
bytes: photoBytes,
);
setState(() {
_stagedFiles.add(platformFile);
});
}
} finally {
setState(() => _isProcessingLocal = false);
} }
} }
Future<void> _handleFilePicker() async { Future<void> _handleFilePicker() async {
// allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo! final result = await FilePicker.pickFiles(
final result = await FilePicker.pickFiles(allowMultiple: true); allowMultiple: true,
withData: true,
);
if (result != null) { if (result != null) {
setState(() { setState(() {
_stagedFiles.addAll(result.files); _stagedFiles.addAll(result.files);
@@ -294,11 +317,12 @@ class _CustomerMobileUploadScreenState
void _submitAllFiles() { void _submitAllFiles() {
setState(() => _isUploading = true); setState(() => _isUploading = true);
// Diciamo al BLoC di caricare tutti i file. // Lanciamo l'evento del nostro nuovo AttachmentsBloc Agnostico!
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda) context.read<AttachmentsBloc>().add(
final bloc = context.read<CustomerFilesBloc>(); UploadAttachmentsEvent(
bloc.add(UploadMultipleCustomerFilesEvent(_stagedFiles)); pickedFiles: _stagedFiles,
companyId: widget.companyId,
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"! ),
);
} }
} }

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
class UploadSuccessScreen extends StatelessWidget {
const UploadSuccessScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.green.shade50,
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.green.withValues(alpha: 0.3),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: const Icon(Icons.check, size: 80, color: Colors.white),
),
const SizedBox(height: 32),
const Text(
"Upload Completato!",
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
const SizedBox(height: 16),
const Text(
"I file sono stati caricati con successo sulla pratica.\nPuoi chiudere questa pagina o finestra del browser.",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.black54),
),
],
),
),
),
);
}
}

View File

@@ -1,5 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart'; import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
class QrUploadDialog extends StatelessWidget { class QrUploadDialog extends StatelessWidget {
@@ -17,78 +19,84 @@ class QrUploadDialog extends StatelessWidget {
// Usiamo i colori del tema per renderlo coerente col tuo design // Usiamo i colori del tema per renderlo coerente col tuo design
final theme = Theme.of(context); final theme = Theme.of(context);
return AlertDialog( return BlocListener<AttachmentsBloc, AttachmentsState>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), listener: (context, state) {
backgroundColor: theme.colorScheme.surface, Navigator.of(context).pop();
title: Column( },
children: [ listenWhen: (previous, current) =>
Icon( previous.allFiles.length < current.allFiles.length,
Icons.qr_code_scanner, child: AlertDialog(
size: 48, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
color: theme.colorScheme.primary, backgroundColor: theme.colorScheme.surface,
), title: Column(
const SizedBox(height: 16), children: [
Text( Icon(
title, Icons.qr_code_scanner,
textAlign: TextAlign.center, size: 48,
style: const TextStyle(fontWeight: FontWeight.bold), color: theme.colorScheme.primary,
), ),
], const SizedBox(height: 16),
), Text(
content: SizedBox( title,
height: 400, textAlign: TextAlign.center,
width: 350, style: const TextStyle(fontWeight: FontWeight.bold),
child: SingleChildScrollView( ),
child: Column( ],
mainAxisSize: MainAxisSize.min, // Fondamentale per i dialog ),
children: [ content: SizedBox(
const Text( height: 400,
"Inquadra questo codice con la fotocamera del tuo telefono per scattare e caricare i documenti direttamente qui.", width: 350,
textAlign: TextAlign.center, child: SingleChildScrollView(
style: TextStyle(fontSize: 14, color: Colors.grey), child: Column(
), mainAxisSize: MainAxisSize.min, // Fondamentale per i dialog
const SizedBox(height: 24), children: [
// IL CUORE DELLA MAGIA const Text(
Container( "Inquadra questo codice con la fotocamera del tuo telefono per scattare e caricare i documenti direttamente qui.",
padding: const EdgeInsets.all(16), textAlign: TextAlign.center,
decoration: BoxDecoration( style: TextStyle(fontSize: 14, color: Colors.grey),
color: Colors
.white, // Lo sfondo bianco salva la vita sui temi scuri
borderRadius: BorderRadius.circular(16),
), ),
child: QrImageView( const SizedBox(height: 24),
data: deepLinkUrl, Container(
version: QrVersions.auto, padding: const EdgeInsets.all(16),
size: 200.0, decoration: BoxDecoration(
//Opzionale: puoi metterci il logo di FLUX in mezzo! color: Colors
embeddedImage: const AssetImage('assets/images/logo.png'), .white, // Lo sfondo bianco salva la vita sui temi scuri
embeddedImageStyle: const QrEmbeddedImageStyle( borderRadius: BorderRadius.circular(16),
size: Size(40, 40), ),
child: QrImageView(
data: deepLinkUrl,
version: QrVersions.auto,
size: 200.0,
//Opzionale: puoi metterci il logo di FLUX in mezzo!
embeddedImage: const AssetImage('assets/images/logo.png'),
embeddedImageStyle: const QrEmbeddedImageStyle(
size: Size(40, 40),
),
), ),
), ),
), const SizedBox(height: 16),
const SizedBox(height: 16), Text(
Text( "In attesa di file...",
"In attesa di file...", style: TextStyle(
style: TextStyle( fontSize: 12,
fontSize: 12, fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, color: theme.colorScheme.primary,
color: theme.colorScheme.primary, ),
), ),
), const SizedBox(height: 8),
const SizedBox(height: 8), const LinearProgressIndicator(), // Per far capire che è "in ascolto"
const LinearProgressIndicator(), // Per far capire che è "in ascolto" ],
], ),
), ),
), ),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.commonClose),
),
],
actionsAlignment: MainAxisAlignment.center,
), ),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.commonClose),
),
],
actionsAlignment: MainAxisAlignment.center,
); );
} }
} }

View File

@@ -1,120 +0,0 @@
import 'package:flutter/material.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!
),
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

@@ -4,16 +4,16 @@ import 'package:flutter/foundation.dart';
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:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/attachments/data/attachments_repository.dart'; import 'package:flux/features/attachments/data/attachments_repository.dart';
import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart'; import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart';
import 'package:flux/features/attachments/ui/quick_rename_dialog.dart'; import 'package:flux/features/attachments/ui/quick_rename_dialog.dart';
import 'package:flux/features/operations/blocs/operation_files_bloc.dart'; import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/attachments/models/attachment_model.dart'; import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:pdf/widgets.dart' as pw; import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as p; // Se ti serve formattazione core
import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx
class _ExportItem { class _ExportItem {
@@ -30,16 +30,26 @@ class _ExportItem {
}); });
} }
class OperationFilesSection extends StatefulWidget { class SharedAttachmentsSection extends StatefulWidget {
final OperationModel currentOp; final String? parentId;
final String titleForUpload;
final AttachmentParentType parentType;
final Future<String?> Function()? onEnsureEntitySaved;
const OperationFilesSection({super.key, required this.currentOp}); const SharedAttachmentsSection({
super.key,
this.parentId,
this.titleForUpload = 'Cliente_sconosciuto',
required this.parentType,
this.onEnsureEntitySaved,
});
@override @override
State<OperationFilesSection> createState() => _OperationFilesSectionState(); State<SharedAttachmentsSection> createState() =>
_SharedAttachmentsSectionState();
} }
class _OperationFilesSectionState extends State<OperationFilesSection> { class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
String? _exportDirectory; String? _exportDirectory;
@override @override
@@ -59,7 +69,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
Future<void> _selectExportDirectory() async { Future<void> _selectExportDirectory() async {
final String? selectedDirectory = await FilePicker.getDirectoryPath( final String? selectedDirectory = await FilePicker.getDirectoryPath(
dialogTitle: 'Seleziona la cartella di esportazione per TIM/Citrix', dialogTitle: 'Seleziona la cartella di esportazione',
); );
if (selectedDirectory != null) { if (selectedDirectory != null) {
@@ -80,6 +90,32 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
// --- SELEZIONE FILE DAL PC/TELEFONO --- // --- SELEZIONE FILE DAL PC/TELEFONO ---
Future<void> _pickFiles() async { Future<void> _pickFiles() async {
final attachmentsBloc = context.read<AttachmentsBloc>();
String? targetId = attachmentsBloc.state.parentId;
// 🥷 SE L'ID NON C'È (Nuova Operazione), FORZIAMO IL SALVATAGGIO PREVENTIVO!
if (targetId == null || targetId.isEmpty) {
if (widget.onEnsureEntitySaved != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Salvataggio rapido scheda per allegati... ⏳'),
duration: Duration(seconds: 1),
),
);
// Chiamiamo la funzione passata dal TicketForm/OperationForm
targetId = await widget.onEnsureEntitySaved!();
}
// Se il salvataggio fallisce (es. form non valido), ci fermiamo per evitare file orfani
if (targetId == null || targetId.isEmpty) return;
// Comunichiamo immediatamente al BLoC che l'entità padre è stata salvata e ha un nuovo ID.
// Questo eviterà che i file finiscano nei `localFiles` temporanei.
attachmentsBloc.add(ParentEntitySavedEvent(targetId));
}
// Ora che abbiamo la certezza matematica di avere un targetId, apriamo il picker
final result = await FilePicker.pickFiles( final result = await FilePicker.pickFiles(
allowMultiple: true, allowMultiple: true,
type: FileType.custom, type: FileType.custom,
@@ -88,17 +124,15 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
); );
if (result != null && mounted) { if (result != null && mounted) {
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC! // Ora il BLoC eseguirà l'ambiente di "Upload immediato" (Bivio 2) perché ha l'ID aggiornato!
context.read<OperationFilesBloc>().add( attachmentsBloc.add(AddAttachmentsEvent(result.files));
AddOperationFilesEvent(result.files),
);
} }
} }
// --- APERTURA VIEWER --- // --- APERTURA VIEWER ---
void _openFile(AttachmentModel file) { void _openFile(AttachmentModel file) {
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare // 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
final operationFilesBloc = context.read<OperationFilesBloc>(); final operationFilesBloc = context.read<AttachmentsBloc>();
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -108,10 +142,10 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
attachment: file, attachment: file,
onRename: (newName) { onRename: (newName) {
// Spara l'evento al BLoC e lui farà il resto! // Spara l'evento al BLoC e lui farà il resto!
operationFilesBloc.add(RenameOperationFileEvent(file, newName)); operationFilesBloc.add(RenameAttachmentEvent(file, newName));
}, },
onDelete: () { onDelete: () {
operationFilesBloc.add(DeleteSpecificOperationFileEvent(file)); operationFilesBloc.add(DeleteSpecificAttachmentEvent(file));
}, },
), ),
), ),
@@ -145,7 +179,8 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
fileBytes = file.localBytes; fileBytes = file.localBytes;
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) { } else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
fileBytes = await repository.downloadAttachmentBytes( fileBytes = await repository.downloadAttachmentBytes(
file.storagePath!, storagePath: file.storagePath!,
bucket: Bucket.documents,
); );
} }
@@ -184,7 +219,8 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
suggestedName = selectedFiles.first.name; suggestedName = selectedFiles.first.name;
} else { } else {
// Se sono più file uniti // Se sono più file uniti
suggestedName = '${widget.currentOp.customerDisplayName}_Unito';
suggestedName = '${widget.titleForUpload}_Unito';
} }
if (!mounted) return; if (!mounted) return;
@@ -274,14 +310,15 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
fileBytes = file.localBytes; fileBytes = file.localBytes;
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) { } else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
fileBytes = await repository.downloadAttachmentBytes( fileBytes = await repository.downloadAttachmentBytes(
file.storagePath!, storagePath: file.storagePath!,
bucket: Bucket.documents,
); );
} }
if (fileBytes == null) continue; if (fileBytes == null) continue;
// Recuperiamo il nome che l'utente ha (magari) già impostato // Recuperiamo il nome che l'utente ha (magari) già impostato
final baseName = file.name ?? 'Documento'; final baseName = file.name;
if (file.extension == 'pdf') { if (file.extension == 'pdf') {
final document = await px.PdfDocument.openData(fileBytes); final document = await px.PdfDocument.openData(fileBytes);
@@ -392,8 +429,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
// USIAMO IL TUO BLOC! return BlocBuilder<AttachmentsBloc, AttachmentsState>(
return BlocBuilder<OperationFilesBloc, OperationFilesState>(
builder: (context, state) { builder: (context, state) {
final allFiles = state.allFiles; final allFiles = state.allFiles;
final selectedFiles = state.selectedFiles; final selectedFiles = state.selectedFiles;
@@ -416,7 +452,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
title: const Text( title: const Text(
'Cartella Export (Es. Citrix TIM)', 'Cartella Export PDF',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
subtitle: Text( subtitle: Text(
@@ -443,9 +479,77 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.add_photo_alternate), icon: const Icon(Icons.add_photo_alternate),
label: const Text('Aggiungi File'), label: const Text('Aggiungi File'),
onPressed: state.status == OperationFilesStatus.uploading onPressed: state.status == AttachmentsStatus.uploading
? null ? null
: _pickFiles, : _pickFiles,
/* : () {
final bloc = context.read<AttachmentsBloc>();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => BlocProvider.value(
value: bloc,
child: SharedMobileUploadScreen(
title: widget.titleForUpload,
),
),
),
);
}, */
),
const SizedBox(width: 8),
Tooltip(
message: 'Carica foto con lo smartphone',
child: IconButton(
icon: const Icon(Icons.qr_code_scanner),
color: theme.colorScheme.primary, // Sempre colorato!
onPressed: () async {
String? targetId = state.parentId;
// SE L'ID NON C'È, CHIAMIAMO IL SALVATAGGIO IN BACKGROUND!
if (targetId == null) {
if (widget.onEnsureEntitySaved != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Salvataggio rapido scheda in corso... ⏳',
),
duration: Duration(seconds: 1),
),
);
// Aspettiamo che il TicketFormCubit faccia il suo lavoro
targetId = await widget.onEnsureEntitySaved!();
}
// Se fallisce (es. validazione form non passata), ci fermiamo
if (targetId == null) return;
}
// GENERAZIONE DEL DEEP LINK AGNOSTICO
final companyId = GetIt.I
.get<SessionCubit>()
.state
.company!
.id!;
final deepLink =
'https://flux.catelli.it/upload/${state.parentType.name}/$targetId?companyId=$companyId';
if (context.mounted) {
final attachmentBloc = context.read<AttachmentsBloc>();
showDialog(
context: context,
builder: (_) => BlocProvider.value(
value: attachmentBloc,
child: QrUploadDialog(
deepLinkUrl: deepLink,
title: 'Carica File: ${widget.titleForUpload}',
),
),
);
}
},
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -464,12 +568,12 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
), ),
onPressed: () { onPressed: () {
if (selectedFiles.length == allFiles.length) { if (selectedFiles.length == allFiles.length) {
context.read<OperationFilesBloc>().add( context.read<AttachmentsBloc>().add(
ClearOperationFileSelectionEvent(), ClearAttachmentSelectionEvent(),
); );
} else { } else {
context.read<OperationFilesBloc>().add( context.read<AttachmentsBloc>().add(
SelectAllOperationFilesEvent(), SelectAllAttachmentsEvent(),
); );
} }
}, },
@@ -478,15 +582,13 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
const SizedBox(width: 12), const SizedBox(width: 12),
// Loader di upload // Loader di upload
if (state.status == OperationFilesStatus.uploading) if (state.status == AttachmentsStatus.uploading)
const SizedBox( const SizedBox(
width: 24, width: 24,
height: 24, height: 24,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
), ),
const Spacer(),
// Azioni visibili SOLO se c'è una selezione! // Azioni visibili SOLO se c'è una selezione!
if (hasSelection) ...[ if (hasSelection) ...[
// Bottone Elimina // Bottone Elimina
@@ -494,21 +596,21 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
icon: const Icon(Icons.delete, color: Colors.red), icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Elimina selezionati', tooltip: 'Elimina selezionati',
onPressed: () { onPressed: () {
context.read<OperationFilesBloc>().add( context.read<AttachmentsBloc>().add(
DeleteOperationFilesEvent(), DeleteAttachmentsEvent(),
); );
}, },
), ),
// Bottone Associa a Cliente // Bottone Associa a Cliente
if (widget.currentOp.customerId != null && if (widget.parentId != null && widget.parentId != '')
widget.currentOp.customerId!.isNotEmpty)
IconButton( IconButton(
icon: const Icon(Icons.person_add, color: Colors.blue), icon: const Icon(Icons.person_add, color: Colors.blue),
tooltip: 'Copia nei documenti del Cliente', tooltip: 'Copia nei documenti del Cliente',
onPressed: () { onPressed: () {
context.read<OperationFilesBloc>().add( context.read<AttachmentsBloc>().add(
LinkFilesToCustomerEvent( LinkAttachmentsToEntityEvent(
customerId: widget.currentOp.customerId!, targetId: widget.parentId!,
targetType: AttachmentParentType.customer,
), ),
); );
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -585,6 +687,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: const Column( child: const Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.upload_file, size: 48, color: Colors.grey), Icon(Icons.upload_file, size: 48, color: Colors.grey),
SizedBox(height: 8), SizedBox(height: 8),
@@ -622,8 +725,8 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
onTap: () => _openFile(file), onTap: () => _openFile(file),
onLongPress: () { onLongPress: () {
// Selezione rapida con long press! // Selezione rapida con long press!
context.read<OperationFilesBloc>().add( context.read<AttachmentsBloc>().add(
ToggleOperationFileSelectionEvent(file), ToggleAttachmentSelectionEvent(file),
); );
}, },
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -697,8 +800,8 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
right: 4, right: 4,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
context.read<OperationFilesBloc>().add( context.read<AttachmentsBloc>().add(
ToggleOperationFileSelectionEvent(file), ToggleAttachmentSelectionEvent(file),
); );
}, },
child: Container( child: Container(

View File

@@ -0,0 +1,361 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
import 'package:flux/features/customers/blocs/customers_list_cubit.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
class SharedCustomerSection extends StatelessWidget {
final CustomerModel? customer;
final ValueChanged<CustomerModel> onCustomerSelected;
const SharedCustomerSection({
super.key,
this.customer,
required this.onCustomerSelected,
});
@override
Widget build(BuildContext context) {
final hasCustomer = customer != null && customer!.id!.isNotEmpty;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
'Cliente',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
InkWell(
onTap: () => _showCustomerModal(context), // Passiamo il context!
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.primary),
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.2),
),
child: Row(
children: [
const Icon(Icons.person),
const SizedBox(width: 12),
Expanded(
child: Text(
hasCustomer ? customer!.name : 'Seleziona Cliente *',
style: TextStyle(
fontWeight: hasCustomer
? FontWeight.bold
: FontWeight.normal,
color: hasCustomer ? null : Colors.grey,
),
),
),
const Icon(Icons.search),
if (hasCustomer) ...[
const SizedBox(width: 12),
IconButton(
onPressed: () async {
final updatedCustomer = await context.pushNamed(
Routes.customerForm,
pathParameters: {'id': customer!.id!},
extra: customer,
);
if (updatedCustomer != null &&
updatedCustomer is CustomerModel) {
onCustomerSelected(updatedCustomer);
}
},
icon: const Icon(Icons.edit),
),
],
],
),
),
),
if (hasCustomer &&
(customer!.phoneNumber.isNotEmpty ||
customer!.email.isNotEmpty)) ...[
const SizedBox(height: 12), // Un po' più di respiro dal box sopra
// Mettiamo i contatti in un Container con un po' di stile per farli sembrare una "Contact Card" integrata
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.05), // Sfondo leggerissimo
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
),
child: Column(
children: [
// --- RIGA TELEFONO ---
if (customer!.phoneNumber.isNotEmpty)
Row(
children: [
// Usiamo i pulsanti "Small" per non occupare troppo spazio verticale
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () => launchUrl(
Uri.parse('https://wa.me/39${customer!.phoneNumber}'),
),
icon: const FaIcon(
FontAwesomeIcons.whatsapp,
color: Colors.green,
size: 20,
),
tooltip: 'Invia WhatsApp',
),
const SizedBox(width: 8),
Expanded(
// Expanded evita l'overflow se il numero è assurdamente lungo
child: SelectableText(
customer!.phoneNumber,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
Clipboard.setData(
ClipboardData(text: customer!.phoneNumber),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Telefono copiato!'),
duration: Duration(seconds: 2),
),
);
},
icon: const Icon(
Icons.copy,
size: 18,
color: Colors.grey,
),
tooltip: 'Copia',
),
],
),
// Sezione divisoria se ci sono entrambi
if (customer!.phoneNumber.isNotEmpty &&
customer!.email.isNotEmpty)
const Divider(height: 8, thickness: 0.5),
// --- RIGA EMAIL ---
if (customer!.email.isNotEmpty)
Row(
children: [
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () => launchUrl(
Uri.parse('mailto:${customer!.email}'),
), // Rimosso il // dopo mailto:, è più sicuro
icon: const FaIcon(
FontAwesomeIcons.envelope,
color: Colors.blue,
size: 20,
),
tooltip: 'Invia Email',
),
const SizedBox(width: 8),
Expanded(
// L'Expanded è vitale per le email che possono essere lunghissime
child: SelectableText(
customer!.email,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
Clipboard.setData(
ClipboardData(text: customer!.email),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Email copiata!'),
duration: Duration(seconds: 2),
),
);
},
icon: const Icon(
Icons.copy,
size: 18,
color: Colors.grey,
),
tooltip: 'Copia',
),
],
),
],
),
),
],
],
);
}
// --- MODALE SELEZIONE CLIENTE ---
void _showCustomerModal(BuildContext context) {
String currentSearchQuery = '';
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.8,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Cliente',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
// Barra di Ricerca
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: 'Cerca per nome, telefono o email...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) {
currentSearchQuery = query;
context.read<CustomersListCubit>().searchCustomers(query);
},
),
),
// Pulsante Nuovo Cliente
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.person_add),
label: const Text('Crea Nuovo Cliente'),
onPressed: () async {
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
final newCustomer = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<CustomersListCubit>(),
child: BlocProvider<CustomerFormCubit>(
create: (context) => CustomerFormCubit(),
child: QuickCustomerDialog(
initialQuery:
currentSearchQuery, // <-- Passiamo quello che ha digitato!
),
),
);
},
);
// Se l'ha creato davvero (e non ha premuto annulla)...
if (newCustomer != null) {
// 1. Aggiorniamo il form delle operazioni
onCustomerSelected(newCustomer);
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
if (context.mounted) {
Navigator.pop(modalContext);
}
}
},
),
),
const Divider(),
// Lista Clienti dal Bloc
Expanded(
child: BlocBuilder<CustomersListCubit, CustomersListState>(
builder: (context, state) {
if (state.status == CustomersListStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.customers.isEmpty) {
return const Center(
child: Text(
'Nessun cliente trovato.',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
controller: scrollController,
itemCount: state.customers.length,
itemBuilder: (context, index) {
final customer = state.customers[index];
return ListTile(
leading: CircleAvatar(
child: Text(
customer.name.substring(0, 1).toUpperCase(),
),
),
title: Text(
customer.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
'${customer.phoneNumber}${customer.email}',
),
onTap: () {
onCustomerSelected(customer);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
import 'package:flux/features/master_data/products/ui/quick_product_dialog.dart';
class SharedModelSection extends StatelessWidget {
final String? modelId;
final String? modelName;
final String label;
final Color? backgroundColor;
final Color? borderColor;
// Usiamo una callback che passa direttamente ID e Nome
// così non dobbiamo preoccuparci di importare la classe esatta del modello ovunque
final void Function(String id, String name) onModelSelected;
const SharedModelSection({
super.key,
required this.modelId,
required this.modelName,
required this.onModelSelected,
this.label = 'Seleziona Modello',
this.backgroundColor,
this.borderColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasModel = modelId != null && modelId!.isNotEmpty;
return ListTile(
tileColor: backgroundColor,
title: Text(label),
subtitle: Text(
hasModel ? modelName! : 'Nessun modello selezionato',
style: TextStyle(
color: hasModel ? null : Colors.grey,
fontWeight: hasModel ? FontWeight.bold : FontWeight.normal,
),
),
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: borderColor ?? theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () => _showModelModal(context),
);
}
void _showModelModal(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Modello',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
autofocus: true,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Cerca modello (es. iPhone 15...)',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) =>
context.read<ProductsCubit>().searchModels(query),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.add),
label: const Text('Aggiungi Modello al Volo'),
onPressed: () async {
// Leggiamo i brand dal Cubit per passarli alla dialog
final existingBrands = context
.read<ProductsCubit>()
.state
.brands;
final newModel = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<ProductsCubit>(),
child: QuickProductDialog(
existingBrands: existingBrands,
),
);
},
);
if (newModel != null) {
// CHIAMIAMO LA CALLBACK!
onModelSelected(newModel.id, newModel.nameWithBrand);
if (context.mounted) Navigator.pop(modalContext);
}
},
),
),
const Divider(),
Expanded(
child: BlocBuilder<ProductsCubit, ProductState>(
builder: (context, state) {
return ListView.builder(
controller: scrollController,
itemCount: state.models.length,
itemBuilder: (context, index) {
final deviceModel = state.models[index];
return ListTile(
leading: const Icon(Icons.devices),
title: Text(
deviceModel.nameWithBrand,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onTap: () {
// CHIAMIAMO LA CALLBACK!
onModelSelected(
deviceModel.id!,
deviceModel.nameWithBrand,
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,246 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/widgets/image_upload/blocs/image_upload_cubit.dart';
import 'package:flux/core/widgets/image_upload/ui/image_upload_screen.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:get_it/get_it.dart';
class SharedFilesSection extends StatelessWidget {
final String titleNameForUpload;
// LA NOSTRA CALLBACK MAGICA
final Future<String?> Function()? onGenerateIdForQr;
const SharedFilesSection({
super.key,
required this.titleNameForUpload,
this.onGenerateIdForQr,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Allegati e Foto',
style: TextStyle(fontWeight: FontWeight.bold),
),
BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) {
return Row(
children: [
// --- IL TASTO QR CODE (Ora sempre attivo!) ---
Tooltip(
message: 'Carica foto con lo smartphone',
child: IconButton(
icon: const Icon(Icons.qr_code_scanner),
color: theme.colorScheme.primary, // Sempre colorato!
onPressed: () async {
String? targetId = state.parentId;
// SE L'ID NON C'È, CHIAMIAMO IL SALVATAGGIO IN BACKGROUND!
if (targetId == null) {
if (onGenerateIdForQr != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Salvataggio rapido scheda in corso... ⏳',
),
duration: Duration(seconds: 1),
),
);
// Aspettiamo che il TicketFormCubit faccia il suo lavoro
targetId = await onGenerateIdForQr!();
}
// Se fallisce (es. validazione form non passata), ci fermiamo
if (targetId == null) return;
}
// GENERAZIONE DEL DEEP LINK AGNOSTICO
final companyId = GetIt.I
.get<SessionCubit>()
.state
.company!
.id!;
final deepLink =
'https://flux.catelli.it/upload/${state.parentType.name}/$targetId?companyId=$companyId';
if (context.mounted) {
showDialog(
context: context,
builder: (_) => QrUploadDialog(
deepLinkUrl: deepLink,
title: 'Carica File: $titleNameForUpload',
),
);
}
},
),
),
const SizedBox(width: 8),
// --- IL TASTO AGGIUNGI CLASSICO (da PC) ---
TextButton.icon(
icon: const Icon(Icons.add_a_photo),
label: const Text('Aggiungi'),
onPressed: () {
final bloc = context.read<AttachmentsBloc>();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => BlocProvider.value(
value: bloc,
child: BlocProvider<ImageUploadCubit>(
create: (context) => ImageUploadCubit(),
child: ImageUploadScreen(
title: titleNameForUpload,
companyId: GetIt.I
.get<SessionCubit>()
.state
.company!
.id!,
),
),
),
),
);
},
),
],
);
},
),
],
),
const SizedBox(height: 8),
// --- LA VETRINA DEI FILE (Identica a prima) ---
BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) {
final files = state.allFiles;
if (state.status == AttachmentsStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (files.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border.all(
color: theme.dividerColor,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(12),
),
child: const Column(
children: [
Icon(
Icons.image_not_supported_outlined,
color: Colors.grey,
size: 32,
),
SizedBox(height: 8),
Text(
'Nessun file allegato',
style: TextStyle(color: Colors.grey),
),
],
),
);
}
return Wrap(
spacing: 12,
runSpacing: 12,
children: files.map((file) {
final isImage = [
'jpg',
'jpeg',
'png',
'webp',
].contains(file.extension.toLowerCase());
return Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor),
),
child: Stack(
children: [
Center(
child: isImage
? const Icon(
Icons.image,
color: Colors.blue,
size: 40,
)
: const Icon(
Icons.picture_as_pdf,
color: Colors.red,
size: 40,
),
),
if (file.id == null)
Positioned(
bottom: 4,
left: 4,
right: 4,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Da salvare',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
),
),
Positioned(
top: -8,
right: -8,
child: IconButton(
icon: const Icon(
Icons.cancel,
color: Colors.redAccent,
size: 20,
),
onPressed: () {
context.read<AttachmentsBloc>().add(
DeleteSpecificAttachmentEvent(file),
);
},
),
),
],
),
);
}).toList(),
);
},
),
],
);
}
}

View File

@@ -2,23 +2,29 @@ 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/features/master_data/staff/blocs/staff_cubit.dart'; import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart'; import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
// IMPORTA IL TUO CUBIT DELLO STAFF
// import 'package:flux/features/staff/blocs/staff_cubit.dart';
class StaffSection extends StatelessWidget { class StaffSection extends StatelessWidget {
final OperationModel? currentOp; final String? label;
final String? staffId;
final String? staffName;
final ValueChanged<StaffMemberModel> onStaffSelected;
const StaffSection({super.key, required this.currentOp}); const StaffSection({
super.key,
required this.onStaffSelected,
this.label,
this.staffId,
this.staffName,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
// Se staffId è nullo, proviamo a preselezionare l'utente loggato
final selectedStaffId = final selectedStaffId =
currentOp?.staffId ?? staffId ?? GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -26,7 +32,8 @@ class StaffSection extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.only(bottom: 12.0), padding: const EdgeInsets.only(bottom: 12.0),
child: Text( child: Text(
'Operatore', label ??
'Operatore', // <-- FIX: Ora usa l'etichetta passata dal form!
style: theme.textTheme.titleLarge?.copyWith( style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -34,8 +41,28 @@ class StaffSection extends StatelessWidget {
), ),
BlocBuilder<StaffCubit, StaffState>( BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) { builder: (context, state) {
// Dati finti per farti vedere la UI, piallali quando attacchi il BlocBuilder! // FIX: Aggiunto un controllo se sta caricando
if (state.status == StaffStatus.loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final staffMembers = state.storeStaff; final staffMembers = state.storeStaff;
// FIX: Feedback visivo se la lista è vuota
if (staffMembers.isEmpty) {
return const Text(
'Nessun operatore caricato. Controlla il Cubit!',
style: TextStyle(color: Colors.red),
);
}
final currentLoggedStaffMember = GetIt.I final currentLoggedStaffMember = GetIt.I
.get<SessionCubit>() .get<SessionCubit>()
.state .state
@@ -49,11 +76,7 @@ class StaffSection extends StatelessWidget {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
// Aggiorniamo la form con un solo tap! onStaffSelected(staff);
context.read<OperationsCubit>().updateOperationFields(
staffId: staff.id,
staffDisplayName: staff.name,
);
}, },
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),

View File

@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_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:flutter_bloc/flutter_bloc.dart';
// Importa il tuo StaffModel
/// Funzione helper globale per lanciare la modale ovunque ti trovi con 1 riga di codice
Future<dynamic> showStaffSelectorModal(BuildContext context) async {
return showModalBottomSheet(
context: context,
isScrollControlled:
true, // Permette alla modale di essere più alta se serve
backgroundColor: Colors.transparent,
builder: (context) => const StaffSelectorModal(),
);
}
class StaffSelectorModal extends StatelessWidget {
const StaffSelectorModal({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
padding: const EdgeInsets.all(24),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min, // Occupa solo lo spazio necessario
children: [
// --- Maniglietta superiore (UX standard dei BottomSheet) ---
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
// --- Titolo ---
const Text(
'Chi sei?',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Seleziona il tuo profilo per continuare',
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 32),
BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) {
if (state.status == StaffStatus.loading) {
return const CircularProgressIndicator();
}
final staffList = state.storeStaff;
return _buildStaffGrid(context, staffList);
},
),
const SizedBox(height: 16),
// --- Tasto Annulla ---
TextButton(
onPressed: () => Navigator.of(context).pop(), // Restituisce null
child: const Text('Annulla'),
),
],
),
),
);
}
Widget _buildStaffGrid(
BuildContext context,
List<StaffMemberModel> staffList,
) {
return Wrap(
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
children: staffList.map((staff) {
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
// Quando l'utente tappa il suo nome, la modale si chiude
// e restituisce il modello (o l'ID) alla schermata precedente!
Navigator.of(context).pop(staff);
},
child: Container(
width: 100, // Pulsanti larghi e comodi
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: Column(
children: [
CircleAvatar(
radius: 30,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
child: Text(
staff.name.substring(0, 1).toUpperCase(),
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
Text(
staff.name,
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}).toList(),
);
}
}
Future<StaffMemberModel?> getStaffMember(BuildContext context) async {
final sessionState = context.read<SessionCubit>().state;
if (sessionState.isSingleUserMode) {
// Dispositivo personale: non rompiamo le palle. Usiamo l'utente loggato.
return sessionState.currentStaffMember;
} else {
// Dispositivo Condiviso (Kiosk Mode): Chiediamo chi è!
return await showStaffSelectorModal(context);
}
}

View File

@@ -0,0 +1,433 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/data/attachments_repository.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:get_it/get_it.dart';
import 'package:image_picker/image_picker.dart';
part 'attachments_events.dart';
part 'attachments_state.dart';
class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
final _repository = GetIt.I.get<AttachmentsRepository>();
AttachmentsBloc({String? parentId, required AttachmentParentType parentType})
: super(
AttachmentsState(
status: AttachmentsStatus.initial,
parentId: parentId,
parentType: parentType,
),
) {
on<ParentEntitySavedEvent>(_onParentEntitySaved);
on<LoadAttachmentsEvent>(_onLoadAttachments);
on<AddAttachmentsEvent>(_onAddAttachments);
on<UploadAttachmentsEvent>(_onUploadAttachments);
on<DeleteAttachmentsEvent>(_onDeleteAttachments);
on<ToggleAttachmentSelectionEvent>(_onToggleAttachmentSelection);
on<LinkAttachmentsToEntityEvent>(_onLinkAttachmentsToEntity);
on<RenameAttachmentEvent>(_onRenameAttachment);
on<DeleteSpecificAttachmentEvent>(_onDeleteSpecificAttachment);
on<SelectAllAttachmentsEvent>(_onSelectAllAttachments);
on<ClearAttachmentSelectionEvent>(_onClearAttachmentSelection);
final currentCompanyId = GetIt.I.get<SessionCubit>().state.company?.id;
if (parentId != null && currentCompanyId != null) {
add(LoadAttachmentsEvent(parentId: parentId));
}
}
FutureOr<void> _onParentEntitySaved(
ParentEntitySavedEvent event,
Emitter<AttachmentsState> emit,
) async {
final companyId = GetIt.I.get<SessionCubit>().state.company?.id;
emit(
state.copyWith(
parentId: event.newParentId,
status: AttachmentsStatus.uploading,
),
);
if (state.localFiles.isNotEmpty) {
try {
final List<Future<void>> uploadTasks = state.localFiles.map((file) {
final fakePlatformFile = PlatformFile(
name: '${file.name}.${file.extension}',
size: file.fileSize,
bytes: file.localBytes,
);
// Chiamiamo il metodo generico passando il parentId e il TYPE
return _repository.uploadAndRegisterFile(
parentId: event.newParentId,
parentType: state.parentType,
pickedFile: fakePlatformFile,
companyId: companyId!,
bucket: _getBucketForParentType,
);
}).toList();
await Future.wait(uploadTasks);
} catch (e) {
emit(
state.copyWith(
status: AttachmentsStatus.failure,
error: "Errore upload post-salvataggio: $e",
),
);
return;
}
}
emit(state.copyWith(localFiles: [], status: AttachmentsStatus.ready));
add(LoadAttachmentsEvent(parentId: event.newParentId));
}
FutureOr<void> _onLoadAttachments(
LoadAttachmentsEvent event,
Emitter<AttachmentsState> emit,
) async {
final currentId = event.parentId ?? state.parentId;
if (currentId != null) {
emit(state.copyWith(status: AttachmentsStatus.loading));
await emit.forEach(
_repository.getFilesStream(
currentId,
state.parentType,
), // Passiamo il tipo!
onData: (List<AttachmentModel> data) =>
state.copyWith(status: AttachmentsStatus.ready, remoteFiles: data),
onError: (error, stackTrace) => state.copyWith(
status: AttachmentsStatus.failure,
error: error.toString(),
),
);
}
}
void _onAddAttachments(
AddAttachmentsEvent event,
Emitter<AttachmentsState> emit,
) async {
final currentId = state.parentId;
final currentCompanyId = GetIt.I.get<SessionCubit>().state.company?.id;
if (currentCompanyId == null) {
emit(
state.copyWith(
status: AttachmentsStatus.failure,
error: "Company ID non trovato nella sessione",
),
);
return;
}
// BIVIO 1: PRATICA NUOVA (Salvataggio locale in memoria)
if (currentId == null) {
final newLocalFiles = event.files.map((file) {
// FISCHIO SALVAVITA PER DESKTOP: se i bytes sono nulli, li leggiamo dal path fisico!
Uint8List? rawBytes = file.bytes;
if (rawBytes == null && file.path != null) {
rawBytes = File(file.path!).readAsBytesSync();
}
return AttachmentModel(
id: null,
companyId: currentCompanyId,
operationId: state.parentType == AttachmentParentType.operation
? ''
: null,
ticketId: state.parentType == AttachmentParentType.ticket ? '' : null,
customerId: state.parentType == AttachmentParentType.customer
? ''
: null,
name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(),
storagePath: '',
fileSize: file.size,
localBytes: rawBytes, // Ora i byte ci sono al 100% anche su Mac!
);
}).toList();
emit(
state.copyWith(
localFiles: [...state.localFiles, ...newLocalFiles],
status: AttachmentsStatus.ready,
),
);
return;
}
// BIVIO 2: PRATICA ESISTENTE (Upload immediato)
emit(state.copyWith(status: AttachmentsStatus.uploading));
try {
final List<Future<void>> uploadTasks = event.files.map((file) {
return _repository.uploadAndRegisterFile(
parentId: currentId,
parentType: state.parentType,
pickedFile: file,
companyId: currentCompanyId,
bucket: _getBucketForParentType,
);
}).toList();
await Future.wait(uploadTasks);
emit(state.copyWith(status: AttachmentsStatus.ready));
} catch (e) {
emit(
state.copyWith(status: AttachmentsStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onUploadAttachments(
UploadAttachmentsEvent event,
Emitter<AttachmentsState> emit,
) async {
if ((event.pickedFiles == null || event.pickedFiles!.isEmpty) &&
(event.photos == null || event.photos!.isEmpty)) {
return;
}
if (state.parentId == null) return;
emit(state.copyWith(status: AttachmentsStatus.uploading));
try {
final List<Future<void>> uploadTasks = [];
// 1. Gestione Documenti normali (PlatformFile)
if (event.pickedFiles != null) {
for (var file in event.pickedFiles!) {
uploadTasks.add(
_repository.uploadAndRegisterFile(
parentId: state.parentId!,
parentType: state.parentType,
pickedFile: file,
companyId: event.companyId,
bucket: _getBucketForParentType,
),
);
}
}
// 2. Gestione Foto Fotocamera (XFile)
if (event.photos != null) {
for (var photo in event.photos!) {
// Leggiamo i byte asincronamente
final bytes = await photo.readAsBytes();
final fileSize = await photo.length();
// Lo travestiamo da PlatformFile per passarlo al Repository!
final fakePlatformFile = PlatformFile(
name: photo.name,
size: fileSize,
bytes: bytes,
path: photo.path,
);
uploadTasks.add(
_repository.uploadAndRegisterFile(
parentId: state.parentId!,
parentType: state.parentType,
pickedFile: fakePlatformFile,
companyId: event.companyId,
bucket: _getBucketForParentType,
),
);
}
}
// Esecuzione parallela di tutti i documenti e foto
await Future.wait(uploadTasks);
emit(state.copyWith(status: AttachmentsStatus.success));
} catch (e) {
emit(
state.copyWith(status: AttachmentsStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onDeleteAttachments(
DeleteAttachmentsEvent event,
Emitter<AttachmentsState> emit,
) async {
emit(state.copyWith(status: AttachmentsStatus.loading));
try {
await _repository.deleteFiles(
files: state.selectedFiles,
currentContextType: state.parentType,
bucket: _getBucketForParentType,
);
emit(state.copyWith(status: AttachmentsStatus.ready, selectedFiles: []));
} catch (e) {
emit(
state.copyWith(status: AttachmentsStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onToggleAttachmentSelection(
ToggleAttachmentSelectionEvent event,
Emitter<AttachmentsState> emit,
) {
final selectedFiles = List<AttachmentModel>.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file);
} else {
selectedFiles.add(event.file);
}
emit(state.copyWith(selectedFiles: selectedFiles));
}
void _onSelectAllAttachments(
SelectAllAttachmentsEvent event,
Emitter<AttachmentsState> emit,
) {
// Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati
emit(state.copyWith(selectedFiles: state.allFiles));
}
void _onClearAttachmentSelection(
ClearAttachmentSelectionEvent event,
Emitter<AttachmentsState> emit,
) {
// Svuotiamo brutalmente la lista
emit(state.copyWith(selectedFiles: []));
}
FutureOr<void> _onLinkAttachmentsToEntity(
LinkAttachmentsToEntityEvent event,
Emitter<AttachmentsState> emit,
) async {
if (state.selectedFiles.isEmpty) return;
// BIVIO 1: PRATICA/TICKET NON ANCORA SALVATA (Modalità Locale)
if (state.parentId == null) {
final updatedLocalFiles = state.localFiles.map((file) {
if (state.selectedFiles.contains(file)) {
// Assegniamo dinamicamente l'ID in base all'entità scelta
switch (event.targetType) {
case AttachmentParentType.customer:
return file.copyWith(customerId: event.targetId);
case AttachmentParentType.ticket:
return file.copyWith(ticketId: event.targetId);
case AttachmentParentType.operation:
return file.copyWith(operationId: event.targetId);
case AttachmentParentType.shippingDocument:
return file.copyWith(shippingDocumentId: event.targetId);
case AttachmentParentType.note:
return file.copyWith(noteId: event.targetId);
}
}
return file;
}).toList();
emit(
state.copyWith(
localFiles: updatedLocalFiles,
selectedFiles: [], // Svuotiamo la selezione
status: AttachmentsStatus.ready,
),
);
return;
}
// BIVIO 2: PRATICA/TICKET ESISTENTE (Modalità Remota su DB)
emit(state.copyWith(status: AttachmentsStatus.loading));
try {
final List<Future<void>> linkTasks = [];
for (var file in state.selectedFiles) {
if (file.id != null) {
linkTasks.add(
_repository.linkFileToEntity(
fileId: file.id!,
targetType: event.targetType,
targetId: event.targetId,
),
);
}
}
await Future.wait(linkTasks);
// Lo stream aggiornerà automaticamente la UI
emit(state.copyWith(status: AttachmentsStatus.ready, selectedFiles: []));
} catch (e) {
emit(
state.copyWith(
status: AttachmentsStatus.failure,
error: "Errore durante il collegamento: $e",
),
);
}
}
FutureOr<void> _onRenameAttachment(
RenameAttachmentEvent event,
Emitter<AttachmentsState> emit,
) async {
// BIVIO 1: File Locale (Bozza)
if (event.file.localBytes != null) {
final updatedLocalFiles = state.localFiles.map((f) {
if (f == event.file) {
return f.copyWith(name: event.newName);
}
return f;
}).toList();
emit(state.copyWith(localFiles: updatedLocalFiles));
return;
}
// BIVIO 2: File Remoto (Salvato su DB)
emit(state.copyWith(status: AttachmentsStatus.loading));
try {
await _repository.renameAttachment(event.file.id!, event.newName);
emit(state.copyWith(status: AttachmentsStatus.ready));
} catch (e) {
emit(
state.copyWith(
status: AttachmentsStatus.failure,
error: "Errore rinomina: $e",
),
);
}
}
FutureOr<void> _onDeleteSpecificAttachment(
DeleteSpecificAttachmentEvent event,
Emitter<AttachmentsState> emit,
) {
if (event.file.localBytes != null) {
final updatedLocalFiles = state.localFiles
.where((f) => f != event.file)
.toList();
emit(state.copyWith(localFiles: updatedLocalFiles));
}
}
Bucket get _getBucketForParentType {
switch (state.parentType) {
case AttachmentParentType.customer:
return Bucket.documents;
case AttachmentParentType.ticket:
return Bucket.documents;
case AttachmentParentType.operation:
return Bucket.documents;
case AttachmentParentType.shippingDocument:
return Bucket.companyDocuments;
case AttachmentParentType.note:
return Bucket.documents;
}
}
}

View File

@@ -0,0 +1,73 @@
part of 'attachments_bloc.dart';
abstract class AttachmentsEvent extends Equatable {
const AttachmentsEvent();
@override
List<Object?> get props => [];
}
/// Chiamato quando l'entità "padre" (es. il Ticket) viene salvata per la prima volta
class ParentEntitySavedEvent extends AttachmentsEvent {
final String newParentId;
const ParentEntitySavedEvent(this.newParentId);
@override
List<Object?> get props => [newParentId];
}
class LoadAttachmentsEvent extends AttachmentsEvent {
final String? parentId;
const LoadAttachmentsEvent({this.parentId});
}
class AddAttachmentsEvent extends AttachmentsEvent {
final List<PlatformFile> files;
const AddAttachmentsEvent(this.files);
}
class UploadAttachmentsEvent extends AttachmentsEvent {
final List<PlatformFile>? pickedFiles;
final List<XFile>? photos;
final String companyId;
const UploadAttachmentsEvent({
this.pickedFiles,
this.photos,
required this.companyId,
});
}
class DeleteAttachmentsEvent extends AttachmentsEvent {}
class ToggleAttachmentSelectionEvent extends AttachmentsEvent {
final AttachmentModel file;
const ToggleAttachmentSelectionEvent(this.file);
}
class SelectAllAttachmentsEvent extends AttachmentsEvent {}
class ClearAttachmentSelectionEvent extends AttachmentsEvent {}
class LinkAttachmentsToEntityEvent extends AttachmentsEvent {
final AttachmentParentType targetType;
final String targetId;
const LinkAttachmentsToEntityEvent({
required this.targetType,
required this.targetId,
});
@override
List<Object?> get props => [targetType, targetId];
}
class RenameAttachmentEvent extends AttachmentsEvent {
final AttachmentModel file;
final String newName;
const RenameAttachmentEvent(this.file, this.newName);
}
class DeleteSpecificAttachmentEvent extends AttachmentsEvent {
final AttachmentModel file;
const DeleteSpecificAttachmentEvent(this.file);
}

View File

@@ -1,10 +1,31 @@
part of 'operation_files_bloc.dart'; part of 'attachments_bloc.dart';
enum OperationFilesStatus { initial, loading, uploading, success, failure } enum AttachmentsStatus { initial, loading, ready, uploading, success, failure }
class OperationFilesState extends Equatable { enum AttachmentParentType {
const OperationFilesState({ operation('operation_id'),
this.operationId, ticket('ticket_id'),
customer('customer_id'),
shippingDocument('shipping_document_id'),
note('note_id');
final String dbColumn;
const AttachmentParentType(this.dbColumn);
}
class AttachmentsState extends Equatable {
final String? parentId;
final AttachmentParentType parentType;
final AttachmentsStatus status;
final String? error;
final List<AttachmentModel> localFiles;
final List<AttachmentModel> remoteFiles;
final List<AttachmentModel> selectedFiles;
const AttachmentsState({
this.parentId,
required this.parentType,
required this.status, required this.status,
this.error, this.error,
this.localFiles = const [], this.localFiles = const [],
@@ -12,17 +33,10 @@ class OperationFilesState extends Equatable {
this.selectedFiles = const [], this.selectedFiles = const [],
}); });
final String? operationId;
final OperationFilesStatus status;
final String? error;
final List<AttachmentModel> localFiles;
final List<AttachmentModel> remoteFiles;
final List<AttachmentModel> selectedFiles;
@override @override
List<Object?> get props => [ List<Object?> get props => [
operationId, parentId,
parentType,
status, status,
error, error,
localFiles, localFiles,
@@ -32,16 +46,18 @@ class OperationFilesState extends Equatable {
List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles]; List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles];
OperationFilesState copyWith({ AttachmentsState copyWith({
String? operationId, String? parentId,
OperationFilesStatus? status, AttachmentParentType? parentType,
AttachmentsStatus? status,
String? error, String? error,
List<AttachmentModel>? localFiles, List<AttachmentModel>? localFiles,
List<AttachmentModel>? remoteFiles, List<AttachmentModel>? remoteFiles,
List<AttachmentModel>? selectedFiles, List<AttachmentModel>? selectedFiles,
}) { }) {
return OperationFilesState( return AttachmentsState(
operationId: operationId ?? this.operationId, parentId: parentId ?? this.parentId,
parentType: parentType ?? this.parentType,
status: status ?? this.status, status: status ?? this.status,
error: error, error: error,
localFiles: localFiles ?? this.localFiles, localFiles: localFiles ?? this.localFiles,

View File

@@ -1,23 +1,243 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
enum Bucket {
documents('documents'),
companyDocuments('company_documents');
final String value;
const Bucket(this.value);
}
class AttachmentsRepository { class AttachmentsRepository {
final _supabase = Supabase.instance.client; final _supabase = Supabase.instance.client;
/// Scarica i byte di un file direttamente da Supabase Storage /// Scarica i byte di un file direttamente da Supabase Storage
Future<Uint8List> downloadAttachmentBytes(String storagePath) async { Future<Uint8List> downloadAttachmentBytes({
required String storagePath,
required Bucket bucket,
}) async {
try { try {
// ATTENZIONE: Sostituisci 'attachments' con il nome VERO del tuo bucket su Supabase!
// Se il tuo storagePath contiene già il nome del bucket all'inizio,
// assicurati di passargli solo il percorso interno.
final Uint8List bytes = await _supabase.storage final Uint8List bytes = await _supabase.storage
.from('attachments') // <--- NOME DEL TUO BUCKET .from(bucket.value)
.download(storagePath); .download(storagePath);
return bytes; return bytes;
} catch (e) { } catch (e) {
throw Exception("Impossibile scaricare il documento dal cloud: $e"); throw Exception("Impossibile scaricare il documento dal cloud: $e");
} }
} }
/// RESTITUISCE IL NOME DELLA COLONNA DB IN BASE AL TIPO
String _getColumnNameForParent(AttachmentParentType parentType) {
switch (parentType) {
case AttachmentParentType.operation:
return 'operation_id';
case AttachmentParentType.ticket:
return 'ticket_id';
case AttachmentParentType.customer:
return 'customer_id';
case AttachmentParentType.shippingDocument:
return 'shipping_document_id';
case AttachmentParentType.note:
return 'note_id';
}
}
/// RECUPERA I FILE IN TEMPO REALE
Stream<List<AttachmentModel>> getFilesStream(
String parentId,
AttachmentParentType parentType,
) {
final columnName = _getColumnNameForParent(parentType);
return _supabase
.from(Tables.attachments)
.stream(primaryKey: ['id'])
.eq(columnName, parentId)
.map(
(listOfMaps) =>
listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
);
}
/// CARICA IL FILE NELLO STORAGE E LO REGISTRA NEL DB
Future<void> uploadAndRegisterFile({
required String parentId,
required AttachmentParentType parentType,
required String companyId,
required Bucket bucket,
PlatformFile? pickedFile, // Ora è opzionale
Uint8List? rawBytes, // Alternativa: bytes grezzi
String? rawFileName, // Alternativa: nome del file
}) async {
// 🛡️ L'ASSERT NINJA: O c'è il pickedFile, o ci sono i byte e il nome.
// L'assert funziona solo in debug, ma è perfetto per beccare subito errori di chiamata!
assert(
pickedFile != null || (rawBytes != null && rawFileName != null),
'Devi passare o un PlatformFile, oppure rawBytes e rawFileName!',
);
try {
// 1. Normalizziamo i dati in base a cosa ci è stato passato
final Uint8List finalBytes;
final String finalFileName;
final int finalFileSize;
if (pickedFile != null) {
if (pickedFile.bytes == null) {
throw Exception(
"I bytes del file sono vuoti! Ricarica la pagina senza cache.",
);
}
finalBytes = pickedFile.bytes!;
finalFileName = pickedFile.name;
finalFileSize = pickedFile.size;
} else {
// Se pickedFile è null, grazie all'assert sappiamo che questi non lo sono
finalBytes = rawBytes!;
finalFileName = rawFileName!;
finalFileSize = finalBytes.length; // Calcoliamo la size dai byte reali
}
// 2. Estraiamo l'estensione e puliamo il nome
final extension = finalFileName.contains('.')
? finalFileName.split('.').last
: ''; // Fallback se il file non ha estensione
final cleanName = finalFileName
.replaceAll(RegExp(r'[^\w\s\.-]'), '')
.replaceAll(' ', '_');
// 3. Creiamo un path ordinato: idAzienda/tipoEntita/idEntita/timestamp_nomefile
final timestamp = DateTime.now().millisecondsSinceEpoch;
final storagePath =
'$companyId/${parentType.name}/$parentId/${timestamp}_$cleanName';
// 4. Upload su Supabase Storage
await _supabase.storage
.from(bucket.value)
.uploadBinary(
storagePath,
finalBytes,
fileOptions: FileOptions(contentType: _guessContentType(extension)),
);
// 5. Creiamo la mappa per il DB dinamicamente
final Map<String, dynamic> insertData = {
'company_id': companyId,
'name': finalFileName.replaceAll('.$extension', ''),
'extension': extension,
'file_size': finalFileSize,
'storage_path': storagePath,
};
// Inseriamo l'ID nella colonna giusta!
final columnName = _getColumnNameForParent(parentType);
insertData[columnName] = parentId;
// 6. Salviamo su Postgres
await _supabase.from(Tables.attachments).insert(insertData);
} catch (e) {
throw Exception("Errore caricamento: $e");
}
}
/// ELIMINA IL FILE (Scollegamento intelligente)
Future<void> deleteFiles({
required List<AttachmentModel> files,
required AttachmentParentType currentContextType,
required Bucket bucket,
}) async {
if (files.isEmpty) return;
try {
for (var file in files) {
if (file.id == null) continue;
// 1. Capiamo quali collegamenti ha questo file attualmente
final currentLinks = {
AttachmentParentType.operation: file.operationId,
AttachmentParentType.ticket: file.ticketId,
AttachmentParentType.customer: file.customerId,
AttachmentParentType.shippingDocument: file.shippingDocumentId,
};
// 2. Simuliamo la rimozione del collegamento per il contesto attuale
currentLinks[currentContextType] = null;
// 3. Controlliamo se rimangono altri ID valorizzati
final hasOtherActiveLinks = currentLinks.values.any(
(id) => id != null && id.isNotEmpty,
);
if (hasOtherActiveLinks) {
// A. Ci sono ancora altre entità che usano questo file!
// Scolleghiamolo SOLO dal contesto attuale mettendo a NULL la sua colonna
await _supabase
.from(Tables.attachments)
.update({currentContextType.dbColumn: null})
.eq('id', file.id!);
} else {
// B. Nessuno usa più questo file! ELIMINAZIONE FISICA TOTALE.
await _supabase.from(Tables.attachments).delete().eq('id', file.id!);
if (file.storagePath != null) {
await _supabase.storage.from(bucket.value).remove([
file.storagePath!,
]);
}
}
}
} catch (e) {
throw Exception("Errore nell'eliminazione dei file: $e");
}
}
/// RINOMINA UN FILE (Solo nel DB, non cambiamo il file fisico)
Future<void> renameAttachment(String fileId, String newName) async {
try {
await _supabase
.from(Tables.attachments)
.update({'name': newName})
.eq('id', fileId);
} catch (e) {
throw Exception("Errore nella rinomina del file: $e");
}
}
/// ASSOCIA UN FILE A UN'ALTRA ENTITÀ (Modifica il record esistente)
Future<void> linkFileToEntity({
required String fileId,
required AttachmentParentType targetType,
required String targetId,
}) async {
try {
// Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta
await _supabase
.from(Tables.attachments)
.update({targetType.dbColumn: targetId})
.eq('id', fileId);
} catch (e) {
throw Exception("Errore nel collegamento del file: $e");
}
}
// Helper per indovinare il content-type base
String _guessContentType(String extension) {
switch (extension.toLowerCase()) {
case 'pdf':
return 'application/pdf';
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
default:
return 'application/octet-stream';
}
}
} }

View File

@@ -7,6 +7,9 @@ class AttachmentModel extends Equatable {
final DateTime? createdAt; final DateTime? createdAt;
final String? customerId; final String? customerId;
final String? operationId; final String? operationId;
final String? ticketId;
final String? shippingDocumentId;
final String? noteId;
final String name; final String name;
final String extension; final String extension;
final String? storagePath; final String? storagePath;
@@ -19,6 +22,9 @@ class AttachmentModel extends Equatable {
this.createdAt, this.createdAt,
this.customerId, this.customerId,
this.operationId, this.operationId,
this.ticketId,
this.shippingDocumentId,
this.noteId,
required this.name, required this.name,
required this.extension, required this.extension,
this.storagePath, this.storagePath,
@@ -33,6 +39,9 @@ class AttachmentModel extends Equatable {
createdAt, createdAt,
customerId, customerId,
operationId, operationId,
ticketId,
shippingDocumentId,
noteId,
name, name,
extension, extension,
storagePath, storagePath,
@@ -59,6 +68,9 @@ class AttachmentModel extends Equatable {
DateTime? createdAt, DateTime? createdAt,
String? customerId, String? customerId,
String? operationId, String? operationId,
String? ticketId,
String? shippingDocumentId,
String? noteId,
String? name, String? name,
String? extension, String? extension,
String? storagePath, String? storagePath,
@@ -70,6 +82,9 @@ class AttachmentModel extends Equatable {
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
customerId: customerId ?? this.customerId, customerId: customerId ?? this.customerId,
operationId: operationId ?? this.operationId, operationId: operationId ?? this.operationId,
ticketId: ticketId ?? this.ticketId,
shippingDocumentId: shippingDocumentId ?? this.shippingDocumentId,
noteId: noteId ?? this.noteId,
name: name ?? this.name, name: name ?? this.name,
extension: extension ?? this.extension, extension: extension ?? this.extension,
storagePath: storagePath ?? this.storagePath, storagePath: storagePath ?? this.storagePath,
@@ -86,6 +101,9 @@ class AttachmentModel extends Equatable {
: null, : null,
customerId: map['customer_id'] as String?, customerId: map['customer_id'] as String?,
operationId: map['operation_id'] as String?, operationId: map['operation_id'] as String?,
ticketId: map['ticket_id'] as String?,
shippingDocumentId: map['shipping_document_id'] as String?,
noteId: map['note_id'] as String?,
name: map['name'] as String, name: map['name'] as String,
extension: map['extension'] as String, extension: map['extension'] as String,
storagePath: map['storage_path'] as String?, storagePath: map['storage_path'] as String?,
@@ -104,6 +122,9 @@ class AttachmentModel extends Equatable {
'storage_path': storagePath, 'storage_path': storagePath,
'customer_id': customerId, 'customer_id': customerId,
'operation_id': operationId, 'operation_id': operationId,
'ticket_id': ticketId,
'shipping_document_id': shippingDocumentId,
'note_id': noteId,
'file_size': fileSize, 'file_size': fileSize,
'company_id': companyId, 'company_id': companyId,
}; };

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/data/constants.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,125 +74,157 @@ class _AuthScreenState extends State<AuthScreen> {
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32), padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column( child: AutofillGroup(
mainAxisAlignment: MainAxisAlignment.center, child: Column(
children: [ mainAxisAlignment: MainAxisAlignment.center,
// --- LOGO FLUX --- children: [
const FluxLogoAuto(height: 80), // --- LOGO FLUX ---
const SizedBox(height: 60), const FluxLogoAuto(height: 80),
const SizedBox(height: 60),
// --- TITOLO DINAMICO --- // --- TITOLO DINAMICO ---
Text( Text(
state.isLoginMode state.isLoginMode
? context.l10n.authScreenWelcomeBack ? context.l10n.authScreenWelcomeBack
: context.l10n.authScreenCreateAccount, : context.l10n.authScreenCreateAccount,
style: TextStyle( style: TextStyle(
color: context.primaryText, color: context.primaryText,
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
letterSpacing: 1.5, letterSpacing: 1.5,
),
), ),
), const SizedBox(height: 8),
const SizedBox(height: 8), Text(
Text( state.isLoginMode
state.isLoginMode ? context.l10n.authScreenLoginToManageYourBusiness
? context.l10n.authScreenLoginToManageYourBusiness : context
: context .l10n
.l10n .authScreenStartTodayToDigitalizeYourStore,
.authScreenStartTodayToDigitalizeYourStore, textAlign: TextAlign.center,
textAlign: TextAlign.center, style: TextStyle(color: context.secondaryText),
style: TextStyle(color: context.secondaryText),
),
const SizedBox(height: 40),
// --- CAMPI INPUT ---
FluxTextField(
label: context.l10n.authScreenBusinessEmail,
icon: Icons.email_outlined,
controller: _emailController,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 20),
FluxTextField(
label: 'Password',
icon: Icons.lock_outline,
isPassword: true, // Magia del FluxTextField!
controller: _passwordController,
onSubmitted: (_) =>
_submit(), // Se lo supporti nel tuo widget custom
),
const SizedBox(height: 40),
// --- BOTTONE PRINCIPALE ---
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
state.isLoginMode
? context.l10n.authScreenLogin
: context.l10n.authScreenSignUp,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
), ),
), const SizedBox(height: 40),
// --- SWITCH LOGIN/SIGNUP --- // --- CAMPI INPUT ---
const SizedBox(height: 24), FluxTextField(
TextButton( label: context.l10n.authScreenBusinessEmail,
onPressed: isLoading icon: Icons.email_outlined,
? null controller: _emailController,
: () => context.read<AuthCubit>().toggleMode(), keyboardType: TextInputType.emailAddress,
child: RichText( autofillHints: const [
text: TextSpan( AutofillHints.email,
text: state.isLoginMode AutofillHints.username,
? context.l10n.authScreenDontHaveAccount ],
: context.l10n.authScreenAlreadyHaveAccount, ),
style: TextStyle(color: context.secondaryText), const SizedBox(height: 20),
children: [ FluxTextField(
TextSpan( label: 'Password',
text: state.isLoginMode icon: Icons.lock_outline,
? context.l10n.authScreenSignUp isPassword: true, // Magia del FluxTextField!
: context.l10n.authScreenLogin, controller: _passwordController,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) =>
_submit(), // Se lo supporti nel tuo widget custom
),
// Link "Dimenticato password?" solo in Login mode
if (state.isLoginMode) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: isLoading
? null
: () => context
.read<AuthCubit>()
.requestPasswordReset(
_emailController.text.trim(),
),
child: Text(
context.l10n.authScreenForgotPassword,
style: TextStyle( style: TextStyle(
color: context.accent, color: context.accent,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
], ),
),
],
const SizedBox(height: 40),
// --- BOTTONE PRINCIPALE ---
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
state.isLoginMode
? context.l10n.authScreenLogin
: context.l10n.authScreenSignUp,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
), ),
), ),
),
if (state.isLoginMode) ...[ // --- SWITCH LOGIN/SIGNUP ---
const SizedBox(height: 24), const SizedBox(height: 24),
TextButton( TextButton(
onPressed: () => context onPressed: isLoading
.read<AuthCubit>() ? null
.requestPasswordReset(_emailController.text.trim()), : () => context.read<AuthCubit>().toggleMode(),
child: Text( child: RichText(
context.l10n.authScreenForgotPassword, text: TextSpan(
style: TextStyle( text: state.isLoginMode
color: context.accent, ? context.l10n.authScreenDontHaveAccount
fontWeight: FontWeight.bold, : context.l10n.authScreenAlreadyHaveAccount,
style: TextStyle(color: context.secondaryText),
children: [
TextSpan(
text: state.isLoginMode
? context.l10n.authScreenSignUp
: context.l10n.authScreenLogin,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
),
),
],
), ),
), ),
), ),
if (state.isLoginMode) ...[
const SizedBox(height: 24),
TextButton(
onPressed: () =>
context.read<AuthCubit>().requestPasswordReset(
_emailController.text.trim(),
),
child: Text(
context.l10n.authScreenForgotPassword,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
),
),
),
],
], ],
], ),
), ),
), ),
), ),

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

@@ -1,33 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/company/data/company_repository.dart';
import 'package:flux/features/company/models/company_model.dart';
import 'package:get_it/get_it.dart';
part 'company_events.dart';
part 'company_state.dart';
class CompanyBloc extends Bloc<CompanyEvent, CompanyState> {
final CompanyRepository _repository = GetIt.I<CompanyRepository>();
CompanyBloc() : super(const CompanyState(status: CompanyStatus.initial)) {
on<CreateCompanyRequested>((event, emit) async {
emit(const CompanyState(status: CompanyStatus.loading));
try {
final createdCompany = await _repository.createCompany(event.company);
emit(
state.copyWith(
status: CompanyStatus.success,
company: createdCompany,
),
);
} catch (e) {
emit(
state.copyWith(
status: CompanyStatus.failure,
errorMessage: e.toString(),
),
);
}
});
}
}

View File

@@ -1,19 +0,0 @@
part of 'company_bloc.dart';
// lib/blocs/company/company_event.dart
abstract class CompanyEvent extends Equatable {
const CompanyEvent();
@override
List<Object?> get props => [];
}
class CreateCompanyRequested extends CompanyEvent {
final CompanyModel company;
const CreateCompanyRequested({required this.company});
@override
List<Object?> get props => [company];
}

View File

@@ -0,0 +1,131 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; // Per kIsWeb
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/company/data/company_repository.dart';
import 'package:flux/features/company/models/company_model.dart';
import 'package:get_it/get_it.dart';
part 'company_settings_state.dart';
class CompanySettingsCubit extends Cubit<CompanySettingsState> {
final CompanyRepository _repository = GetIt.I<CompanyRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
CompanySettingsCubit() : super(const CompanySettingsState());
void initSettings() {
final currentCompany = _sessionCubit.state.company;
if (currentCompany != null) {
emit(
state.copyWith(
company: currentCompany,
status: CompanySettingsStatus.ready,
),
);
}
}
void updateFields({
String? name,
String? vatId, // Modificato da vatNumber a vatId
String? fiscalCode, // Aggiunto
String? sdi, // Aggiunto
String? address,
String? city,
String? province, // Aggiunto
String? zipCode,
String? phone,
String? email,
String? ticketDisclaimer,
LabelFormat? labelFormat,
double? labelWidth,
double? labelHeight,
bool? isVertical,
}) {
if (state.company == null) return;
final updated = state.company!.copyWith(
name: name ?? state.company!.name,
vatId: vatId ?? state.company!.vatId,
fiscalCode: fiscalCode ?? state.company!.fiscalCode,
sdi: sdi ?? state.company!.sdi,
address: address ?? state.company!.address,
city: city ?? state.company!.city,
province: province ?? state.company!.province,
zipCode: zipCode ?? state.company!.zipCode,
phone: phone ?? state.company!.phone,
email: email ?? state.company!.email,
ticketDisclaimer: ticketDisclaimer ?? state.company!.ticketDisclaimer,
labelFormat: labelFormat ?? state.company!.labelFormat,
labelWidth: labelWidth ?? state.company!.labelWidth,
labelHeight: labelHeight ?? state.company!.labelHeight,
isLabelVertical: isVertical ?? state.company!.isLabelVertical,
);
emit(state.copyWith(company: updated));
}
Future<void> saveSettings() async {
if (state.company == null) return;
emit(
state.copyWith(status: CompanySettingsStatus.saving, errorMessage: null),
);
try {
// 1. Salva i dati su Supabase
final updatedCompany = await _repository.updateCompany(state.company!);
// 2. Aggiorna la sessione globale per riflettere i cambiamenti in tutta l'app
_sessionCubit.updateCurrentCompany(updatedCompany);
emit(
state.copyWith(
status: CompanySettingsStatus.success,
company: updatedCompany,
),
);
} catch (e) {
emit(
state.copyWith(
status: CompanySettingsStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// Metodo per gestire l'upload del logo
Future<void> uploadLogo(Uint8List bytes, String fileName) async {
if (state.company == null) return;
emit(state.copyWith(status: CompanySettingsStatus.uploadingLogo));
try {
// Usa il tuo repository per caricare il file nel bucket 'company_logos'
// Il file può essere Uint8List (se sei su Web) o File (se sei su Mobile/Desktop)
final publicUrl = await _repository.uploadCompanyLogo(
companyId: state.company!.id!,
fileBytes: bytes,
fileName: fileName,
);
final updatedCompany = state.company!.copyWith(logoUrl: publicUrl);
emit(
state.copyWith(
company: updatedCompany,
status: CompanySettingsStatus.ready,
),
);
// Chiamiamo il salvataggio per rendere definitivo l'URL nel record della compagnia
await saveSettings();
} catch (e) {
emit(
state.copyWith(
status: CompanySettingsStatus.failure,
errorMessage: "Errore caricamento logo: $e",
),
);
}
}
}

View File

@@ -0,0 +1,37 @@
part of 'company_settings_cubit.dart';
class CompanySettingsState extends Equatable {
final CompanySettingsStatus status;
final CompanyModel? company;
final String? errorMessage;
const CompanySettingsState({
this.status = CompanySettingsStatus.initial,
this.company,
this.errorMessage,
});
CompanySettingsState copyWith({
CompanySettingsStatus? status,
CompanyModel? company,
String? errorMessage,
}) {
return CompanySettingsState(
status: status ?? this.status,
company: company ?? this.company,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => [status, company, errorMessage];
}
enum CompanySettingsStatus {
initial,
ready,
saving,
uploadingLogo,
success,
failure,
}

View File

@@ -1,26 +0,0 @@
part of 'company_bloc.dart';
enum CompanyStatus { initial, loading, success, failure }
class CompanyState extends Equatable {
final CompanyStatus status;
final String? errorMessage;
final CompanyModel? company;
const CompanyState({required this.status, this.errorMessage, this.company});
CompanyState copyWith({
CompanyStatus? status,
String? errorMessage,
CompanyModel? company,
}) {
return CompanyState(
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
company: company ?? this.company,
);
}
@override
List<Object?> get props => [status, errorMessage, company];
}

View File

@@ -1,3 +1,6 @@
import 'dart:typed_data';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/company_model.dart'; import '../models/company_model.dart';
@@ -8,7 +11,7 @@ class CompanyRepository {
try { try {
// .select().single() trasforma la risposta nell'oggetto appena inserito // .select().single() trasforma la risposta nell'oggetto appena inserito
final response = await _supabase final response = await _supabase
.from('company') .from(Tables.companies)
.insert(company.toMap()) .insert(company.toMap())
.select() .select()
.single(); .single();
@@ -21,11 +24,67 @@ class CompanyRepository {
} }
} }
Future<CompanyModel> updateCompany(CompanyModel company) async {
try {
final response = await _supabase
.from(Tables.companies)
.update(company.toMap())
.eq('id', company.id!)
.select()
.single();
return CompanyModel.fromMap(response);
} on PostgrestException catch (e) {
throw e.message;
} catch (e) {
throw e.toString();
}
}
Future<String> uploadCompanyLogo({
required String companyId,
required Uint8List fileBytes,
required String fileName,
}) async {
try {
// 1. Prepariamo il path.
// Organizziamo per companyId e aggiungiamo un timestamp per evitare cache del browser
// quando l'utente cambia logo più volte.
final extension = fileName.split('.').last;
final timestamp = DateTime.now().millisecondsSinceEpoch;
final filePath = '$companyId/logo_$timestamp.$extension';
// 2. Caricamento fisico dei bytes
// Usiamo uploadBinary che è perfetto per Uint8List
await _supabase.storage
.from('company_logos')
.uploadBinary(
filePath,
fileBytes,
fileOptions: const FileOptions(
cacheControl: '3600',
upsert:
true, // Se esiste già un file con lo stesso nome, lo sovrascrive
),
);
// 3. Otteniamo l'URL pubblico.
// Nota: il bucket 'company_logos' deve essere impostato come PUBLIC su Supabase
final String publicUrl = _supabase.storage
.from('company_logos')
.getPublicUrl(filePath);
return publicUrl;
} catch (e) {
throw Exception("Errore durante l'upload del logo: $e");
}
}
Future<CompanyModel?> getCompany() async { Future<CompanyModel?> getCompany() async {
try { try {
final userId = _supabase.auth.currentUser?.id; final userId = _supabase.auth.currentUser?.id;
final response = await _supabase final response = await _supabase
.from('company') .from(Tables.companies)
.select() .select()
.eq('user_id', userId as Object) .eq('user_id', userId as Object)
.maybeSingle(); .maybeSingle();

View File

@@ -35,6 +35,21 @@ enum SubscriptionStatus {
} }
} }
enum LabelFormat {
none,
small_62x29,
medium_54x101,
large_102x152,
custom;
static LabelFormat fromString(String? value) {
return LabelFormat.values.firstWhere(
(e) => e.name == value,
orElse: () => LabelFormat.none,
);
}
}
// =================================================================== // ===================================================================
// IL MODELLO ESATTO // IL MODELLO ESATTO
// =================================================================== // ===================================================================
@@ -53,8 +68,14 @@ class CompanyModel extends Equatable {
final String vatId; final String vatId;
final String fiscalCode; final String fiscalCode;
final String sdi; final String sdi;
final String companyLogo; final String? phone;
final String? email;
final String? logoUrl;
final String? ticketDisclaimer;
final LabelFormat labelFormat;
final double? labelWidth;
final double? labelHeight;
final bool isLabelVertical;
// Stato Pagamenti (Ibride: manuale + Stripe) // Stato Pagamenti (Ibride: manuale + Stripe)
final bool isPaid; final bool isPaid;
final DateTime? paymentExpiration; final DateTime? paymentExpiration;
@@ -78,7 +99,14 @@ class CompanyModel extends Equatable {
required this.vatId, required this.vatId,
required this.fiscalCode, required this.fiscalCode,
required this.sdi, required this.sdi,
this.companyLogo = '', this.phone,
this.email,
this.logoUrl,
this.ticketDisclaimer,
this.labelFormat = LabelFormat.none,
this.labelWidth,
this.labelHeight,
this.isLabelVertical = false,
this.isPaid = false, this.isPaid = false,
this.paymentExpiration, this.paymentExpiration,
this.subscriptionTier = SubscriptionTier.free, this.subscriptionTier = SubscriptionTier.free,
@@ -100,7 +128,14 @@ class CompanyModel extends Equatable {
String? vatId, String? vatId,
String? fiscalCode, String? fiscalCode,
String? sdi, String? sdi,
String? companyLogo, String? logoUrl,
String? ticketDisclaimer,
LabelFormat? labelFormat,
double? labelWidth,
double? labelHeight,
bool? isLabelVertical,
String? phone,
String? email,
bool? isPaid, bool? isPaid,
DateTime? paymentExpiration, DateTime? paymentExpiration,
SubscriptionTier? subscriptionTier, SubscriptionTier? subscriptionTier,
@@ -121,7 +156,14 @@ class CompanyModel extends Equatable {
vatId: vatId ?? this.vatId, vatId: vatId ?? this.vatId,
fiscalCode: fiscalCode ?? this.fiscalCode, fiscalCode: fiscalCode ?? this.fiscalCode,
sdi: sdi ?? this.sdi, sdi: sdi ?? this.sdi,
companyLogo: companyLogo ?? this.companyLogo, logoUrl: logoUrl ?? this.logoUrl,
phone: phone ?? this.phone,
email: email ?? this.email,
ticketDisclaimer: ticketDisclaimer ?? this.ticketDisclaimer,
labelFormat: labelFormat ?? this.labelFormat,
labelWidth: labelWidth ?? this.labelWidth,
labelHeight: labelHeight ?? this.labelHeight,
isLabelVertical: isLabelVertical ?? this.isLabelVertical,
isPaid: isPaid ?? this.isPaid, isPaid: isPaid ?? this.isPaid,
paymentExpiration: paymentExpiration ?? this.paymentExpiration, paymentExpiration: paymentExpiration ?? this.paymentExpiration,
subscriptionTier: subscriptionTier ?? this.subscriptionTier, subscriptionTier: subscriptionTier ?? this.subscriptionTier,
@@ -163,7 +205,18 @@ class CompanyModel extends Equatable {
vatId: map['vat_id'] ?? '', vatId: map['vat_id'] ?? '',
fiscalCode: map['fiscal_code'] ?? '', fiscalCode: map['fiscal_code'] ?? '',
sdi: map['sdi'] ?? '', sdi: map['sdi'] ?? '',
companyLogo: map['company_logo'] ?? '', logoUrl: map['logo_url'],
phone: map['phone'] ?? '',
email: map['email'] ?? '',
ticketDisclaimer: map['ticket_disclaimer'],
labelFormat: LabelFormat.fromString(map['label_format']),
labelWidth: map['label_width'] != null
? (map['label_width'] as num).toDouble()
: null,
labelHeight: map['label_height'] != null
? (map['label_height'] as num).toDouble()
: null,
isLabelVertical: map['is_label_vertical'] ?? false,
isPaid: map['is_paid'] ?? false, isPaid: map['is_paid'] ?? false,
paymentExpiration: map['payment_expiration'] != null paymentExpiration: map['payment_expiration'] != null
? DateTime.tryParse(map['payment_expiration']) ? DateTime.tryParse(map['payment_expiration'])
@@ -193,7 +246,14 @@ class CompanyModel extends Equatable {
'vat_id': vatId, 'vat_id': vatId,
'fiscal_code': fiscalCode, 'fiscal_code': fiscalCode,
'sdi': sdi, 'sdi': sdi,
'company_logo': companyLogo, 'logo_url': logoUrl,
'phone': phone,
'email': email,
'ticket_disclaimer': ticketDisclaimer,
'label_format': labelFormat.name,
'label_width': labelWidth,
'label_height': labelHeight,
'is_label_vertical': isLabelVertical,
'is_paid': isPaid, 'is_paid': isPaid,
if (paymentExpiration != null) if (paymentExpiration != null)
'payment_expiration': paymentExpiration!.toIso8601String(), 'payment_expiration': paymentExpiration!.toIso8601String(),
@@ -221,7 +281,14 @@ class CompanyModel extends Equatable {
vatId, vatId,
fiscalCode, fiscalCode,
sdi, sdi,
companyLogo, logoUrl,
phone,
email,
ticketDisclaimer,
labelFormat,
labelWidth,
labelHeight,
isLabelVertical,
isPaid, isPaid,
paymentExpiration, paymentExpiration,
subscriptionTier, subscriptionTier,

View File

@@ -0,0 +1,452 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/company/bloc/company_settings_cubit.dart';
import 'package:flux/features/company/models/company_model.dart';
import 'package:flux/features/settings/document_sequence/blocs/document_sequence_cubit.dart';
import 'package:flux/features/settings/document_sequence/ui/document_sequence_section.dart';
import 'package:image_picker/image_picker.dart';
class CompanySettingsScreen extends StatefulWidget {
const CompanySettingsScreen({super.key});
@override
State<CompanySettingsScreen> createState() => _CompanySettingsScreenState();
}
class _CompanySettingsScreenState extends State<CompanySettingsScreen> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _vatCtrl = TextEditingController();
final _fiscalCodeCtrl = TextEditingController(); // Nuovo
final _sdiCtrl = TextEditingController(); // Nuovo
final _addressCtrl = TextEditingController();
final _cityCtrl = TextEditingController();
final _provinceCtrl = TextEditingController(); // Nuovo
final _zipCtrl = TextEditingController();
final _phoneCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _disclaimerCtrl = TextEditingController();
bool _isInitialized = false;
@override
void initState() {
super.initState();
final cubit = context.read<CompanySettingsCubit>();
cubit.initSettings();
if (cubit.state.status == CompanySettingsStatus.ready &&
cubit.state.company != null) {
_syncControllers(cubit.state.company!);
}
}
@override
void dispose() {
_nameCtrl.dispose();
_vatCtrl.dispose();
_fiscalCodeCtrl.dispose(); // Nuovo
_sdiCtrl.dispose(); // Nuovo
_addressCtrl.dispose();
_cityCtrl.dispose();
_provinceCtrl.dispose(); // Nuovo
_zipCtrl.dispose();
_phoneCtrl.dispose();
_emailCtrl.dispose();
_disclaimerCtrl.dispose();
super.dispose();
}
void _syncControllers(CompanyModel company) {
if (_nameCtrl.text.isEmpty) _nameCtrl.text = company.name;
if (_vatCtrl.text.isEmpty) _vatCtrl.text = company.vatId;
if (_fiscalCodeCtrl.text.isEmpty) {
_fiscalCodeCtrl.text = company.fiscalCode; // Nuovo
}
if (_sdiCtrl.text.isEmpty) _sdiCtrl.text = company.sdi; // Nuovo
if (_provinceCtrl.text.isEmpty) {
_provinceCtrl.text = company.province; // Nuovo
}
if (_addressCtrl.text.isEmpty) _addressCtrl.text = company.address;
if (_cityCtrl.text.isEmpty) _cityCtrl.text = company.city;
if (_zipCtrl.text.isEmpty) _zipCtrl.text = company.zipCode;
if (_phoneCtrl.text.isEmpty) _phoneCtrl.text = company.phone ?? '';
if (_emailCtrl.text.isEmpty) _emailCtrl.text = company.email ?? '';
_isInitialized = true;
if (_disclaimerCtrl.text.isEmpty) {
_disclaimerCtrl.text = company.ticketDisclaimer ?? '';
}
}
void _flushToCubit() {
context.read<CompanySettingsCubit>().updateFields(
name: _nameCtrl.text,
vatId: _vatCtrl.text,
fiscalCode: _fiscalCodeCtrl.text, // Nuovo
sdi: _sdiCtrl.text, // Nuovo
province: _provinceCtrl.text,
address: _addressCtrl.text,
city: _cityCtrl.text,
zipCode: _zipCtrl.text,
phone: _phoneCtrl.text,
email: _emailCtrl.text,
ticketDisclaimer: _disclaimerCtrl.text,
);
}
Future<void> _pickAndUploadLogo() async {
final picker = ImagePicker();
final companySettingsCubit = context.read<CompanySettingsCubit>();
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null && mounted) {
// Passiamo i bytes per compatibilità totale con Flutter Web
final bytes = await pickedFile.readAsBytes();
companySettingsCubit.uploadLogo(bytes, pickedFile.name);
}
}
void _onLabelFormatChanged(LabelFormat selectedFormat) {
double? w;
double? h;
switch (selectedFormat) {
case LabelFormat.small_62x29:
w = 62.0;
h = 29.0;
break;
case LabelFormat.medium_54x101:
w = 54.0;
h = 101.0;
break;
case LabelFormat.large_102x152:
w = 102.0;
h = 152.0;
break;
case LabelFormat.custom:
case LabelFormat.none:
// Lasciamo i valori null o quelli vecchi
break;
}
context.read<CompanySettingsCubit>().updateFields(
labelFormat: selectedFormat,
labelWidth: w,
labelHeight: h,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Impostazioni Azienda')),
body: BlocConsumer<CompanySettingsCubit, CompanySettingsState>(
listener: (context, state) {
if (state.status == CompanySettingsStatus.ready && !_isInitialized) {
_syncControllers(state.company!);
}
if (state.status == CompanySettingsStatus.success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Impostazioni salvate con successo!'),
backgroundColor: Colors.green,
),
);
}
if (state.status == CompanySettingsStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Errore'),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
if (state.company == null) {
return const Center(child: CircularProgressIndicator());
}
final company = state.company!;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
// --- SEZIONE LOGO ---
Center(
child: Stack(
alignment: Alignment.bottomRight,
children: [
Container(
height: 120,
width: 120,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: theme.dividerColor),
image: company.logoUrl != null
? DecorationImage(
image: NetworkImage(company.logoUrl!),
fit: BoxFit.contain,
)
: null,
),
child: company.logoUrl == null
? const Icon(
Icons.business,
size: 50,
color: Colors.grey,
)
: null,
),
if (state.status ==
CompanySettingsStatus.uploadingLogo)
const Positioned.fill(
child: Center(child: CircularProgressIndicator()),
),
FloatingActionButton.small(
onPressed:
state.status ==
CompanySettingsStatus.uploadingLogo
? null
: _pickAndUploadLogo,
child: const Icon(Icons.camera_alt),
),
],
),
),
const SizedBox(height: 32),
// --- SEZIONE DATI PRINCIPALI ---
Text(
'Dati Legali',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Divider(),
const SizedBox(height: 16),
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Ragione Sociale',
prefixIcon: Icon(Icons.badge),
),
validator: (val) => val == null || val.isEmpty
? 'Campo obbligatorio'
: null,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _vatCtrl,
decoration: const InputDecoration(
labelText: 'Partita IVA',
prefixIcon: Icon(Icons.receipt_long),
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _fiscalCodeCtrl,
decoration: const InputDecoration(
labelText: 'Codice Fiscale',
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _sdiCtrl,
decoration: const InputDecoration(
labelText: 'Codice SDI',
),
),
),
],
),
const SizedBox(height: 16),
// --- SEZIONE INDIRIZZO E CONTATTI ---
Text(
'Sede e Contatti',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Divider(),
const SizedBox(height: 16),
TextFormField(
controller: _addressCtrl,
decoration: const InputDecoration(
labelText: 'Indirizzo (Via e numero civico)',
prefixIcon: Icon(Icons.location_on),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
controller: _cityCtrl,
decoration: const InputDecoration(
labelText: 'Città',
),
),
),
const SizedBox(width: 16),
Expanded(
flex: 1,
child: TextFormField(
controller: _provinceCtrl,
decoration: const InputDecoration(
labelText: 'Provincia (es. MI)',
),
),
),
const SizedBox(width: 16),
Expanded(
flex: 1,
child: TextFormField(
controller: _zipCtrl,
decoration: const InputDecoration(labelText: 'CAP'),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _phoneCtrl,
decoration: const InputDecoration(
labelText: 'Telefono',
prefixIcon: Icon(Icons.phone),
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _emailCtrl,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
),
),
),
],
),
const SizedBox(height: 16),
BlocProvider(
create: (context) =>
DocumentSequenceCubit(state.company!.id!)
..loadSequences(),
child: const DocumentSequenceSection(),
),
const SizedBox(height: 16),
// Sezione Disclaimer
Text(
"Note Legali Ricevuta",
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
TextFormField(
controller: _disclaimerCtrl,
maxLines: 5,
decoration: const InputDecoration(
hintText:
"Inserisci qui la liberatoria legale che apparirà sulla ricevuta dei ticket...",
border: OutlineInputBorder(),
),
onChanged: (val) => context
.read<CompanySettingsCubit>()
.updateFields(ticketDisclaimer: val),
),
const SizedBox(height: 24),
// Sezione Etichette
Text(
"Configurazione Etichette",
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
DropdownButtonFormField<LabelFormat>(
initialValue: company.labelFormat,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.label_outline),
labelText: "Formato Stampa Etichetta",
),
items: LabelFormat.values
.map(
(f) => DropdownMenuItem(
value: f,
child: Text(
f.name.replaceAll('_', ' ').toUpperCase(),
),
),
)
.toList(),
onChanged: (val) {
if (val != null) {
_onLabelFormatChanged(val);
}
},
),
const SizedBox(height: 48),
// --- PULSANTE SALVATAGGIO ---
SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: state.status == CompanySettingsStatus.saving
? null
: () {
if (_formKey.currentState!.validate()) {
_flushToCubit();
context
.read<CompanySettingsCubit>()
.saveSettings();
}
},
icon: state.status == CompanySettingsStatus.saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Icon(Icons.save),
label: const Text(
'Salva Impostazioni',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
},
),
);
}
}

View File

@@ -1,328 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/company/bloc/company_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/company/models/company_model.dart';
class CreateCompanyScreen extends StatefulWidget {
const CreateCompanyScreen({super.key});
@override
State<CreateCompanyScreen> createState() => _CreateCompanyScreenState();
}
// lib/ui/setup/create_company_screen.dart
class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
final _formKey = GlobalKey<FormState>();
// Controller per i campi obbligatori
final _ragioneSocialeController = TextEditingController();
final _indirizzoController = TextEditingController();
final _capController = TextEditingController();
final _cittaController = TextEditingController();
final _provinciaController = TextEditingController();
final _pIvaController = TextEditingController();
final _cfController = TextEditingController();
final _univocoController = TextEditingController();
@override
void dispose() {
// Ricordati sempre di chiuderli!
_ragioneSocialeController.dispose();
_indirizzoController.dispose();
_capController.dispose();
_cittaController.dispose();
_provinciaController.dispose();
_pIvaController.dispose();
_cfController.dispose();
_univocoController.dispose();
super.dispose();
}
void _onSave() {
if (_formKey.currentState!.validate()) {
// Recuperiamo l'ID utente attuale da Supabase o dal SessionBloc
final userId = context.read<SessionCubit>().state.user!.id;
final company = CompanyModel(
userId: userId,
name: _ragioneSocialeController.text.trim(),
address: _indirizzoController.text.trim(),
zipCode: _capController.text.trim(),
city: _cittaController.text.trim(),
province: _provinciaController.text.trim(),
vatId: _pIvaController.text.trim(),
fiscalCode: _cfController.text.trim(),
sdi: _univocoController.text.trim().toUpperCase(),
// Gli altri campi hanno i default nel modello
);
// Spariamo l'evento al Bloc
context.read<CompanyBloc>().add(CreateCompanyRequested(company: company));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.createCompanyScreenCompanyConfiguration),
actions: [
IconButton(
icon: const Icon(Icons.logout_rounded),
onPressed: () {
// Qui chiami il tuo Bloc dell'autenticazione per fare logout
// Esempio se hai un AuthBloc o SessionBloc:
//context.read<AuthBloc>().add(LogoutRequested());
// Se vuoi solo tornare brutalmente alla login per testare il logo:
// Navigator.of(context).pushReplacementNamed('/login');
},
),
],
),
body: BlocConsumer<CompanyBloc, CompanyState>(
listener: (context, state) {
if (state.status == CompanyStatus.success && state.company != null) {
// 1. Aggiorniamo la singleton con i dati reali (ID incluso)
//GetIt.I.get<AppSettings>().setCurrentCompany(state.company);
// 2. Notifichiamo il SessionBloc per cambiare pagina
//context.read<SessionCubit>().add(AppStarted());
}
if (state.status == CompanyStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
state.errorMessage ?? context.l10n.commonSavingError,
),
backgroundColor: Colors.redAccent,
),
);
}
},
builder: (context, state) {
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(context),
const SizedBox(height: 32),
// --- SEZIONE 1: IDENTITÀ FISCALE ---
_SectionTitle(
title: context.l10n.createCompanyScreenFiscalData,
),
const SizedBox(height: 16),
FluxTextField(
label: context.l10n.createCompanyScreenCompanyName,
icon: Icons.business,
controller: _ragioneSocialeController,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FluxTextField(
label: context.l10n.createCompanyScreenVatId,
icon: Icons.numbers,
controller: _pIvaController,
),
),
const SizedBox(width: 12),
Expanded(
child: FluxTextField(
label: context.l10n.createCompanyScreenFiscalCode,
icon: Icons.badge_outlined,
controller: _cfController,
),
),
],
),
const SizedBox(height: 16),
FluxTextField(
label: context.l10n.createCompanyScreenSdiPec,
icon: Icons.send_and_archive_outlined,
controller: _univocoController,
),
const SizedBox(height: 32),
// --- SEZIONE 2: SEDE LEGALE ---
_SectionTitle(
title:
context.l10n.createCompanyScreenCompanyLegalAddress,
),
const SizedBox(height: 16),
FluxTextField(
label: context.l10n.commonAddress,
icon: Icons.home_work_outlined,
controller: _indirizzoController,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
flex: 2,
child: FluxTextField(
label: context.l10n.commonCity,
icon: Icons.location_city,
controller: _cittaController,
),
),
const SizedBox(width: 12),
Expanded(
child: FluxTextField(
label: context.l10n.commonZipCode,
icon: Icons.map_outlined,
controller: _capController,
),
),
const SizedBox(width: 12),
Expanded(
child: FluxTextField(
label: context.l10n.commonProvince,
icon: Icons.explore_outlined,
controller: _provinciaController,
),
),
],
),
const SizedBox(height: 32),
// --- SEZIONE 3: LOGO AZIENDALE ---
_SectionTitle(title: 'BRANDING'),
const SizedBox(height: 16),
_buildLogoPicker(context),
const SizedBox(height: 48),
// --- BOTTONE INVIO ---
_buildSubmitButton(context, state),
],
),
),
),
);
},
),
);
}
// Placeholder per il futuro caricamento logo
Widget _buildLogoPicker(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: context.accent.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
// Bordo continuo ma sottile e semitrasparente per un look pulito
border: Border.all(
color: context.accent.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
children: [
Icon(Icons.cloud_upload_outlined, color: context.accent, size: 32),
const SizedBox(height: 12),
Text(
context.l10n.createCompanyScreenUploadLogo,
style: TextStyle(
color: context.primaryText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
context.l10n.createCompanyScreenWillBeUsedForReceipts,
textAlign: TextAlign.center,
style: TextStyle(color: context.secondaryText, fontSize: 12),
),
],
),
);
}
Widget _buildSubmitButton(BuildContext context, CompanyState state) {
return SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: state.status == CompanyStatus.loading
? null
: () => _onSave(),
child: state.status == CompanyStatus.loading
? const CircularProgressIndicator()
: Text(context.l10n.createCompanyScreenSaveCompany),
),
);
}
Widget _buildHeader(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.accent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.domain_add_rounded,
color: context.accent,
size: 32,
),
),
const SizedBox(height: 24),
Text(
context.l10n.createCompanyScreenSetupYourCompany,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: context.primaryText,
),
),
const SizedBox(height: 12),
Text(
context.l10n.createCompanyScreenFluxNeedsYourFiscalData,
style: TextStyle(
color: context.secondaryText,
fontSize: 15,
height: 1.5,
),
),
],
);
}
}
// Widget di supporto per i titoli delle sezioni
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) {
return Text(
title,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
fontSize: 13,
),
);
}
}

View File

@@ -1,139 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:get_it/get_it.dart';
part 'customer_files_events.dart';
part 'customer_files_state.dart';
class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final String customerId;
CustomerFilesBloc(this.customerId)
: super(const CustomerFilesState(status: CustomerFilesStatus.initial)) {
on<LoadCustomerFilesEvent>(_loadCustomerFiles);
on<UploadCustomerFileEvent>(_uploadCustomerFile);
on<UploadMultipleCustomerFilesEvent>(_uploadMultipleCustomerFiles);
on<DeleteCustomerFilesEvent>(_deleteCustomerFiles);
on<ToggleCustomerFileSelectionEvent>(_toggleCustomerFileSelection);
}
void _loadCustomerFiles(
LoadCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
await emit.forEach<List<AttachmentModel>>(
_repository.getCustomerFilesStream(customerId),
onData: (customerFiles) => CustomerFilesState(
status: CustomerFilesStatus.success,
customerFiles: customerFiles,
),
onError: (error, stackTrace) => CustomerFilesState(
status: CustomerFilesStatus.failure,
error: error.toString(),
),
);
}
Future<void> _uploadCustomerFile(
UploadCustomerFileEvent event,
Emitter<CustomerFilesState> emit,
) async {
emit(state.copyWith(status: CustomerFilesStatus.uploading));
if (event.pickedFile != null) {
try {
await _repository.uploadAndRegisterFile(
customerId: customerId,
pickedFile: event.pickedFile!,
);
emit(state.copyWith(status: CustomerFilesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: e.toString(),
),
);
}
}
}
FutureOr<void> _uploadMultipleCustomerFiles(
UploadMultipleCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
if (event.files.isEmpty) {
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: "Nessun file selezionato",
),
);
return;
}
emit(state.copyWith(status: CustomerFilesStatus.uploading, error: null));
try {
// 2. Creiamo una lista di "Promesse" (Futures) per il repository
final List<Future<void>> uploadTasks = [];
for (var file in event.files) {
// Aggiungiamo il task alla lista, ma NON usiamo await qui dentro!
uploadTasks.add(
_repository.uploadAndRegisterFile(
customerId: customerId,
pickedFile: file,
),
);
}
// 3. ESECUZIONE PARALLELA!
// Aspettiamo che tutti i file siano caricati contemporaneamente.
await Future.wait(uploadTasks);
// 4. GRAN FINALE: Tutto caricato, emettiamo il success!
emit(state.copyWith(status: CustomerFilesStatus.success));
} catch (e) {
// Se anche un solo file fallisce, catturiamo l'errore
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: "Errore durante l'upload multiplo: $e",
),
);
}
}
Future<void> _deleteCustomerFiles(
DeleteCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
emit(state.copyWith(status: CustomerFilesStatus.loading));
try {
await _repository.deleteDocuments(state.selectedFiles);
emit(
state.copyWith(status: CustomerFilesStatus.success, selectedFiles: []),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: e.toString(),
),
);
}
}
void _toggleCustomerFileSelection(
ToggleCustomerFileSelectionEvent event,
Emitter<CustomerFilesState> emit,
) {
List<AttachmentModel> selectedFiles = List.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file);
} else {
selectedFiles.add(event.file);
}
emit(state.copyWith(selectedFiles: selectedFiles));
}
}

View File

@@ -1,30 +0,0 @@
part of 'customer_files_bloc.dart';
abstract class CustomerFilesEvent extends Equatable {
const CustomerFilesEvent();
@override
List<Object> get props => [];
}
class LoadCustomerFilesEvent extends CustomerFilesEvent {}
class UploadCustomerFileEvent extends CustomerFilesEvent {
final PlatformFile? pickedFile;
final File? photo;
const UploadCustomerFileEvent({this.pickedFile, this.photo});
}
class UploadMultipleCustomerFilesEvent extends CustomerFilesEvent {
final List<PlatformFile> files;
const UploadMultipleCustomerFilesEvent(this.files);
@override
List<Object> get props => [files];
}
class DeleteCustomerFilesEvent extends CustomerFilesEvent {}
class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent {
final AttachmentModel file;
const ToggleCustomerFileSelectionEvent(this.file);
}

View File

@@ -1,34 +0,0 @@
part of 'customer_files_bloc.dart';
enum CustomerFilesStatus { initial, loading, uploading, success, failure }
class CustomerFilesState extends Equatable {
const CustomerFilesState({
required this.status,
this.error,
this.customerFiles = const [],
this.selectedFiles = const [],
});
final CustomerFilesStatus status;
final String? error;
final List<AttachmentModel> customerFiles;
final List<AttachmentModel> selectedFiles;
@override
List<Object?> get props => [status, error, customerFiles, selectedFiles];
CustomerFilesState copyWith({
CustomerFilesStatus? status,
String? error,
List<AttachmentModel>? customerFiles,
List<AttachmentModel>? selectedFiles,
}) {
return CustomerFilesState(
status: status ?? this.status,
error: error,
customerFiles: customerFiles ?? this.customerFiles,
selectedFiles: selectedFiles ?? this.selectedFiles,
);
}
}

View File

@@ -0,0 +1,132 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart';
part 'customer_form_state.dart';
class CustomerFormCubit extends Cubit<CustomerFormState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
CustomerFormCubit({CustomerModel? existingCustomer, String? customerId})
: super(
CustomerFormState(customer: existingCustomer ?? CustomerModel.empty()),
);
Future<void> initForm({
CustomerModel? existingCustomer,
String? customerId,
}) async {
emit(state.copyWith(status: CustomerFormStatus.loading));
try {
if (existingCustomer != null) {
emit(
state.copyWith(
customer: existingCustomer,
status: CustomerFormStatus.ready,
),
);
} else if (customerId != null) {
final customer = await _repository.getCustomerById(customerId);
emit(
state.copyWith(customer: customer, status: CustomerFormStatus.ready),
);
} else {
// Nuovo cliente, inizializziamo con valori vuoti
emit(
state.copyWith(
customer: CustomerModel.empty().copyWith(
companyId: _sessionCubit.state.company!.id!,
),
status: CustomerFormStatus.ready,
),
);
}
} on Exception catch (e) {
emit(
state.copyWith(
status: CustomerFormStatus.failure,
errorMessage: e.toString(),
),
);
}
}
void updateDoNotDisturb(bool value) {
emit(
state.copyWith(customer: state.customer.copyWith(doNotDisturb: value)),
);
}
void updateFields({
String? name,
String? phoneNumber,
String? email,
String? note,
bool? doNotDisturb,
bool? isBusiness,
}) {
emit(
state.copyWith(
customer: state.customer.copyWith(
name: name ?? state.customer.name,
phoneNumber: phoneNumber ?? state.customer.phoneNumber,
email: email ?? state.customer.email,
note: note ?? state.customer.note,
doNotDisturb: doNotDisturb ?? state.customer.doNotDisturb,
isBusiness: isBusiness ?? state.customer.isBusiness,
),
),
);
}
Future<void> saveCustomer() async {
emit(state.copyWith(status: CustomerFormStatus.saving));
try {
if (state.customer.id != null) {
// Aggiorna cliente esistente
await _repository.updateCustomer(state.customer);
} else {
// Crea nuovo cliente
await _repository.insertCustomer(state.customer);
}
emit(state.copyWith(status: CustomerFormStatus.success));
} on Exception catch (e) {
emit(
state.copyWith(
status: CustomerFormStatus.failure,
errorMessage: e.toString(),
),
);
}
}
Future<CustomerModel?> quickCreateCustomer({
required String name,
String? phone,
String? email,
required bool isBusiness,
}) async {
final newCustomer = CustomerModel(
name: name,
phoneNumber: phone ?? '',
email: email ?? '',
companyId: _sessionCubit.state.company!.id!,
note: '',
isBusiness: isBusiness,
);
try {
final saved = await _repository.insertCustomer(newCustomer);
// Lo aggiungeremo in cima ai suggerimenti
return saved;
} catch (e) {
return null;
}
}
}

View File

@@ -0,0 +1,30 @@
part of 'customer_form_cubit.dart';
enum CustomerFormStatus { initial, loading, ready, saving, success, failure }
class CustomerFormState extends Equatable {
final CustomerFormStatus status;
final CustomerModel customer;
final String? errorMessage;
const CustomerFormState({
this.status = CustomerFormStatus.initial,
required this.customer,
this.errorMessage,
});
CustomerFormState copyWith({
CustomerFormStatus? status,
CustomerModel? customer,
String? errorMessage,
}) {
return CustomerFormState(
status: status ?? this.status,
customer: customer ?? this.customer,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => [status, customer, errorMessage];
}

View File

@@ -1,160 +0,0 @@
import 'dart:async'; // Serve per il Timer del debounce
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart';
part 'customers_state.dart';
class CustomersCubit extends Cubit<CustomersState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
// Variabile per gestire il debounce della ricerca
Timer? _searchDebounce;
CustomersCubit() : super(const CustomersState());
// --- LETTURA ---
Future<void> loadCustomers() async {
emit(state.copyWith(status: CustomersStatus.loading));
try {
final customers = await _repository.getCustomers(
_sessionCubit.state.company!.id!,
);
emit(
state.copyWith(status: CustomersStatus.success, customers: customers),
);
} catch (e) {
emit(
state.copyWith(
status: CustomersStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- CREAZIONE ---
Future<void> createCustomer(CustomerModel customer) async {
emit(state.copyWith(status: CustomersStatus.loading));
try {
final newCustomer = await _repository.saveCustomer(customer);
// Aggiorniamo la lista locale aggiungendo il nuovo cliente in cima
final updatedList = List<CustomerModel>.from(state.customers)
..insert(0, newCustomer);
emit(
state.copyWith(
status: CustomersStatus.success,
customers: updatedList,
lastCreatedCustomer: newCustomer,
),
);
} catch (e) {
emit(
state.copyWith(
status: CustomersStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- AGGIORNAMENTO ---
Future<void> updateCustomer(CustomerModel customer) async {
emit(state.copyWith(status: CustomersStatus.loading));
try {
final updatedCustomer = await _repository.updateCustomer(customer);
final updatedList = List<CustomerModel>.from(state.customers);
final index = updatedList.indexWhere((c) => c.id == updatedCustomer.id);
if (index != -1) {
updatedList[index] = updatedCustomer;
}
emit(
state.copyWith(
status: CustomersStatus.success,
customers: updatedList,
lastCreatedCustomer:
updatedCustomer, // Utile se modifichi un cliente appena creato
),
);
} catch (e) {
emit(
state.copyWith(
status: CustomersStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- RICERCA CON DEBOUNCE ---
void searchCustomers(String query) {
// 1. Se c'è già una ricerca in attesa (l'utente sta digitando veloce), la annulliamo
if (_searchDebounce?.isActive ?? false) _searchDebounce!.cancel();
// 2. Facciamo partire un timer di 400 millisecondi
_searchDebounce = Timer(const Duration(milliseconds: 300), () async {
// Se cancella tutto e la query è vuota, ricarichiamo la lista base
if (query.trim().isEmpty) {
await loadCustomers();
return;
}
// Nessun "loading" state qui, per evitare sfarfallii visivi mentre si scrive
try {
final results = await _repository.searchCustomers(
_sessionCubit.state.company!.id!,
query,
);
emit(
state.copyWith(status: CustomersStatus.success, customers: results),
);
} catch (e) {
emit(
state.copyWith(
status: CustomersStatus.failure,
errorMessage: e.toString(),
),
);
}
});
}
Future<CustomerModel?> quickCreateCustomer({
required String name,
String? phone,
String? email,
}) async {
final newCustomer = CustomerModel(
name: name,
phoneNumber: phone ?? '',
email: email ?? '',
companyId: _sessionCubit.state.company!.id!,
note: '',
);
try {
final saved = await _repository.saveCustomer(newCustomer);
// Lo aggiungiamo in cima ai suggerimenti
emit(state.copyWith(customers: [saved, ...state.customers]));
return saved;
} catch (e) {
return null;
}
}
// Pulizia della memoria quando il Cubit viene distrutto
@override
Future<void> close() {
_searchDebounce?.cancel();
return super.close();
}
}

View File

@@ -0,0 +1,85 @@
import 'dart:async'; // Serve per il Timer del debounce
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart';
part 'customers_list_state.dart';
class CustomersListCubit extends Cubit<CustomersListState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
// Variabile per gestire il debounce della ricerca
Timer? _searchDebounce;
CustomersListCubit() : super(const CustomersListState());
// --- LETTURA ---
Future<void> loadCustomers() async {
emit(state.copyWith(status: CustomersListStatus.loading));
try {
final customers = await _repository.getCustomers(
_sessionCubit.state.company!.id!,
);
emit(
state.copyWith(
status: CustomersListStatus.success,
customers: customers,
),
);
} catch (e) {
emit(
state.copyWith(
status: CustomersListStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- RICERCA CON DEBOUNCE ---
void searchCustomers(String query) {
// 1. Se c'è già una ricerca in attesa (l'utente sta digitando veloce), la annulliamo
if (_searchDebounce?.isActive ?? false) _searchDebounce!.cancel();
// 2. Facciamo partire un timer di 400 millisecondi
_searchDebounce = Timer(const Duration(milliseconds: 300), () async {
// Se cancella tutto e la query è vuota, ricarichiamo la lista base
if (query.trim().isEmpty) {
await loadCustomers();
return;
}
// Nessun "loading" state qui, per evitare sfarfallii visivi mentre si scrive
try {
final results = await _repository.searchCustomers(
_sessionCubit.state.company!.id!,
query,
);
emit(
state.copyWith(
status: CustomersListStatus.success,
customers: results,
),
);
} catch (e) {
emit(
state.copyWith(
status: CustomersListStatus.failure,
errorMessage: e.toString(),
),
);
}
});
}
// Pulizia della memoria quando il Cubit viene distrutto
@override
Future<void> close() {
_searchDebounce?.cancel();
return super.close();
}
}

View File

@@ -1,6 +1,6 @@
part of 'customers_cubit.dart'; part of 'customers_list_cubit.dart';
enum CustomersStatus { enum CustomersListStatus {
initial, initial,
loading, loading,
filesLoading, filesLoading,
@@ -9,26 +9,26 @@ enum CustomersStatus {
failure, failure,
} }
class CustomersState extends Equatable { class CustomersListState extends Equatable {
final CustomersStatus status; final CustomersListStatus status;
final List<CustomerModel> customers; final List<CustomerModel> customers;
final CustomerModel? lastCreatedCustomer; final CustomerModel? lastCreatedCustomer;
final String? errorMessage; final String? errorMessage;
const CustomersState({ const CustomersListState({
this.status = CustomersStatus.initial, this.status = CustomersListStatus.initial,
this.customers = const [], this.customers = const [],
this.lastCreatedCustomer, this.lastCreatedCustomer,
this.errorMessage, this.errorMessage,
}); });
CustomersState copyWith({ CustomersListState copyWith({
CustomersStatus? status, CustomersListStatus? status,
List<CustomerModel>? customers, List<CustomerModel>? customers,
CustomerModel? lastCreatedCustomer, CustomerModel? lastCreatedCustomer,
String? errorMessage, String? errorMessage,
}) { }) {
return CustomersState( return CustomersListState(
status: status ?? this.status, status: status ?? this.status,
customers: customers ?? this.customers, customers: customers ?? this.customers,
lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer, lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer,

View File

@@ -1,5 +1,6 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.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/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:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
@@ -11,10 +12,10 @@ class CustomerRepository {
final String companyId = GetIt.I.get<SessionCubit>().state.company!.id!; final String companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
// Crea un nuovo cliente // Crea un nuovo cliente
Future<CustomerModel> saveCustomer(CustomerModel customer) async { Future<CustomerModel> insertCustomer(CustomerModel customer) async {
try { try {
final response = await _supabase final response = await _supabase
.from('customer') .from(Tables.customers)
.upsert(customer.toJson()) .upsert(customer.toJson())
.select() .select()
.single(); .single();
@@ -27,7 +28,7 @@ class CustomerRepository {
Future<CustomerModel> updateCustomer(CustomerModel customer) async { Future<CustomerModel> updateCustomer(CustomerModel customer) async {
try { try {
final response = await _supabase final response = await _supabase
.from('customer') .from(Tables.customers)
.update(customer.toJson()) .update(customer.toJson())
.eq('id', customer.id!) .eq('id', customer.id!)
.select() .select()
@@ -42,14 +43,14 @@ class CustomerRepository {
Future<List<CustomerModel>> getCustomers(String companyId) async { Future<List<CustomerModel>> getCustomers(String companyId) async {
try { try {
final response = await _supabase final response = await _supabase
.from('customer') .from(Tables.customers)
.select(''' .select('''
*, *,
attachment(*) ${Tables.attachments}(*)
''') ''')
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('is_active', true) .eq('is_active', true)
.order('name'); .order('name', ascending: true);
return (response as List).map((c) => CustomerModel.fromMap(c)).toList(); return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
} catch (e) { } catch (e) {
@@ -57,6 +58,23 @@ class CustomerRepository {
} }
} }
Future<CustomerModel> getCustomerById(String customerId) async {
try {
final response = await _supabase
.from(Tables.customers)
.select('''
*,
${Tables.attachments}(*)
''')
.eq('id', customerId)
.single();
return CustomerModel.fromMap(response);
} catch (e) {
throw '$e';
}
}
// Ricerca clienti per nome o telefono (fondamentale per la UX) // Ricerca clienti per nome o telefono (fondamentale per la UX)
Future<List<CustomerModel>> searchCustomers( Future<List<CustomerModel>> searchCustomers(
String companyId, String companyId,
@@ -64,7 +82,7 @@ class CustomerRepository {
) async { ) async {
try { try {
final response = await _supabase final response = await _supabase
.from('customer') .from(Tables.customers)
.select() .select()
.eq('company_id', companyId) .eq('company_id', companyId)
.or('name.ilike.%$query%,phone_number.ilike.%$query%') .or('name.ilike.%$query%,phone_number.ilike.%$query%')
@@ -79,7 +97,7 @@ class CustomerRepository {
/// Ascolta in tempo reale i file caricati per un cliente /// Ascolta in tempo reale i file caricati per un cliente
Stream<List<AttachmentModel>> getCustomerFilesStream(String customerId) { Stream<List<AttachmentModel>> getCustomerFilesStream(String customerId) {
return _supabase return _supabase
.from('attachment') .from(Tables.attachments)
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.eq('customer_id', customerId) .eq('customer_id', customerId)
.order('created_at', ascending: false) .order('created_at', ascending: false)
@@ -93,7 +111,7 @@ class CustomerRepository {
Future<List<AttachmentModel>> getCustomerFiles(String customerId) async { Future<List<AttachmentModel>> getCustomerFiles(String customerId) async {
try { try {
final response = await _supabase final response = await _supabase
.from('attachment') .from(Tables.attachments)
.select() .select()
.eq('customer_id', customerId); .eq('customer_id', customerId);
@@ -144,7 +162,7 @@ class CustomerRepository {
} }
final response = await _supabase final response = await _supabase
.from('attachment') .from(Tables.attachments)
.insert(fileToSave.toMap()) .insert(fileToSave.toMap())
.select() .select()
.single(); .single();
@@ -156,7 +174,7 @@ class CustomerRepository {
} }
Future<void> saveFileReference(AttachmentModel file) async { Future<void> saveFileReference(AttachmentModel file) async {
await _supabase.from('attachment').upsert(file.toMap()); await _supabase.from(Tables.attachments).upsert(file.toMap());
} }
Future<void> deleteDocuments(List<AttachmentModel> files) async { Future<void> deleteDocuments(List<AttachmentModel> files) async {
@@ -175,13 +193,16 @@ class CustomerRepository {
} }
try { try {
if (idsToDelete.isNotEmpty) { if (idsToDelete.isNotEmpty) {
await _supabase.from('attachment').delete().inFilter('id', idsToDelete); await _supabase
.from(Tables.attachments)
.delete()
.inFilter('id', idsToDelete);
// 3. Cancellazione MASSIVA dallo Storage // 3. Cancellazione MASSIVA dallo Storage
await _supabase.storage.from('documents').remove(storagePathsToDelete); await _supabase.storage.from('documents').remove(storagePathsToDelete);
} }
if (idsToEdit.isNotEmpty) { if (idsToEdit.isNotEmpty) {
await _supabase await _supabase
.from('attachment') .from(Tables.attachments)
.update({'customer_id': null}) .update({'customer_id': null})
.inFilter('id', idsToEdit); .inFilter('id', idsToEdit);
} }

View File

@@ -14,6 +14,7 @@ class CustomerModel extends Equatable {
final String companyId; // UUID final String companyId; // UUID
final bool isActive; final bool isActive;
final List<AttachmentModel> attachments; final List<AttachmentModel> attachments;
final bool isBusiness;
const CustomerModel({ const CustomerModel({
this.id, this.id,
@@ -27,6 +28,7 @@ class CustomerModel extends Equatable {
required this.companyId, required this.companyId,
this.isActive = true, this.isActive = true,
this.attachments = const [], this.attachments = const [],
this.isBusiness = false,
}); });
@override @override
@@ -42,8 +44,18 @@ class CustomerModel extends Equatable {
companyId, companyId,
isActive, isActive,
attachments, attachments,
isBusiness,
]; ];
factory CustomerModel.empty() => CustomerModel(
name: '',
phoneNumber: '',
email: '',
note: '',
companyId:
'', // Dovrebbe essere sempre fornito, ma lasciamo vuoto per sicurezza
);
CustomerModel copyWith({ CustomerModel copyWith({
String? id, String? id,
DateTime? createdAt, DateTime? createdAt,
@@ -56,6 +68,7 @@ class CustomerModel extends Equatable {
String? companyId, String? companyId,
bool? isActive, bool? isActive,
List<AttachmentModel>? attachments, List<AttachmentModel>? attachments,
bool? isBusiness,
}) { }) {
return CustomerModel( return CustomerModel(
id: id ?? this.id, id: id ?? this.id,
@@ -69,6 +82,7 @@ class CustomerModel extends Equatable {
companyId: companyId ?? this.companyId, companyId: companyId ?? this.companyId,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
attachments: attachments ?? this.attachments, attachments: attachments ?? this.attachments,
isBusiness: isBusiness ?? this.isBusiness,
); );
} }
@@ -93,6 +107,7 @@ class CustomerModel extends Equatable {
?.map((x) => AttachmentModel.fromMap(x)) ?.map((x) => AttachmentModel.fromMap(x))
.toList() ?? .toList() ??
const [], const [],
isBusiness: map['is_business'] as bool? ?? false,
); );
} }
@@ -108,6 +123,7 @@ class CustomerModel extends Equatable {
'do_not_disturb': doNotDisturb, 'do_not_disturb': doNotDisturb,
'company_id': companyId, 'company_id': companyId,
'is_active': isActive, 'is_active': isActive,
'is_business': isBusiness,
}; };
} }
} }

View File

@@ -6,9 +6,10 @@ import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/widgets/image_viewer_widget.dart'; import 'package:flux/core/widgets/image_viewer_widget.dart';
import 'package:flux/core/widgets/pdf_viewer_widget.dart'; import 'package:flux/core/widgets/pdf_viewer_widget.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart'; import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:flux/features/attachments/models/attachment_model.dart'; import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart';
class CustomerDetailScreen extends StatefulWidget { class CustomerDetailScreen extends StatefulWidget {
final CustomerModel customer; final CustomerModel customer;
@@ -26,11 +27,13 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
} }
void _loadFiles() { void _loadFiles() {
context.read<CustomerFilesBloc>().add(LoadCustomerFilesEvent()); context.read<AttachmentsBloc>().add(
LoadAttachmentsEvent(parentId: widget.customer.id),
);
} }
Future<void> _pickAndUpload() async { Future<void> _pickAndUpload() async {
CustomerFilesBloc customerFilesBloc = context.read<CustomerFilesBloc>(); AttachmentsBloc attachmentsBloc = context.read<AttachmentsBloc>();
// Chiamata statica pulita // Chiamata statica pulita
FilePickerResult? result = await FilePicker.pickFiles( FilePickerResult? result = await FilePicker.pickFiles(
@@ -40,17 +43,18 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
); );
if (result != null) { if (result != null) {
for (var pickedFile in result.files) { try {
try { attachmentsBloc.add(
customerFilesBloc.add( UploadAttachmentsEvent(
UploadCustomerFileEvent(pickedFile: pickedFile), pickedFiles: result.files,
); companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
} catch (e) { ),
if (mounted) { );
ScaffoldMessenger.of(context).showSnackBar( } catch (e) {
SnackBar(content: Text("Errore upload ${pickedFile.name}: $e")), if (mounted) {
); ScaffoldMessenger.of(
} context,
).showSnackBar(SnackBar(content: Text("$e")));
} }
} }
} }
@@ -143,7 +147,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
} }
Widget _buildDocumentSection() { Widget _buildDocumentSection() {
return BlocBuilder<CustomerFilesBloc, CustomerFilesState>( return BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) { builder: (context, state) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -213,9 +217,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
if (state.status == CustomerFilesStatus.loading) if (state.status == AttachmentsStatus.loading)
const Center(child: CircularProgressIndicator()) const Center(child: CircularProgressIndicator())
else if (state.customerFiles.isEmpty) else if (state.allFiles.isEmpty)
const Center(child: Text("Nessun documento presente")) const Center(child: Text("Nessun documento presente"))
else else
Expanded( Expanded(
@@ -226,9 +230,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
crossAxisSpacing: 16, crossAxisSpacing: 16,
childAspectRatio: 1.2, childAspectRatio: 1.2,
), ),
itemCount: state.customerFiles.length, itemCount: state.allFiles.length,
itemBuilder: (context, index) => itemBuilder: (context, index) =>
_FileCard(file: state.customerFiles[index], state: state), _FileCard(file: state.allFiles[index], state: state),
), ),
), ),
], ],
@@ -268,14 +272,14 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
class _FileCard extends StatelessWidget { class _FileCard extends StatelessWidget {
final AttachmentModel file; final AttachmentModel file;
final CustomerFilesState state; final AttachmentsState state;
const _FileCard({required this.file, required this.state}); const _FileCard({required this.file, required this.state});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: () => context.read<CustomerFilesBloc>().add( onTap: () => context.read<AttachmentsBloc>().add(
ToggleCustomerFileSelectionEvent(file), ToggleAttachmentSelectionEvent(file),
), ),
onDoubleTap: () => _handleDoubleClickOnFile(context, file), onDoubleTap: () => _handleDoubleClickOnFile(context, file),
child: Stack( child: Stack(

View File

@@ -1,138 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/customers/models/customer_model.dart'; // Uso il tuo widget!
class CustomerForm extends StatefulWidget {
final CustomerModel? customer; // Se presente, siamo in modalità "Modifica"
final Function(CustomerModel customer) onSave;
const CustomerForm({
super.key,
this.customer, // Opzionale
required this.onSave,
});
@override
State<CustomerForm> createState() => _CustomerFormState();
}
class _CustomerFormState extends State<CustomerForm> {
final _formKey = GlobalKey<FormState>();
// Controller inizializzati con i dati del cliente (se presenti)
late final TextEditingController _nomeController;
late final TextEditingController _telefonoController;
late final TextEditingController _emailController;
late final TextEditingController _noteController;
late bool _nonDisturbare;
@override
void initState() {
super.initState();
// Se widget.customer è null, i campi saranno vuoti
_nomeController = TextEditingController(text: widget.customer?.name ?? '');
_telefonoController = TextEditingController(
text: widget.customer?.phoneNumber ?? '',
);
_emailController = TextEditingController(
text: widget.customer?.email ?? '',
);
_noteController = TextEditingController(text: widget.customer?.note ?? '');
_nonDisturbare = widget.customer?.doNotDisturb ?? false;
}
@override
void dispose() {
_nomeController.dispose();
_telefonoController.dispose();
_emailController.dispose();
_noteController.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState!.validate()) {
// Creiamo un nuovo modello partendo da quello esistente (se c'è)
// o creandone uno da zero, preservando l'ID in caso di modifica.
final updatedCustomer =
widget.customer?.copyWith(
name: _nomeController.text.trim(),
phoneNumber: _telefonoController.text.trim(),
email: _emailController.text.trim(),
note: _noteController.text.trim(),
doNotDisturb: _nonDisturbare,
) ??
CustomerModel(
// Caso nuovo cliente
name: _nomeController.text.trim(),
phoneNumber: _telefonoController.text.trim(),
email: _emailController.text.trim(),
note: _noteController.text.trim(),
doNotDisturb: _nonDisturbare,
companyId: '', // Verrà iniettato dal Bloc o dal chiamante
);
widget.onSave(updatedCustomer);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.customer == null ? 'Nuovo Cliente' : 'Modifica Cliente',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
FluxTextField(
label: 'Nome Completo',
autoFocus: true,
icon: Icons.person_outline,
controller: _nomeController,
),
const SizedBox(height: 16),
FluxTextField(
label: 'Telefono',
icon: Icons.phone_android_outlined,
controller: _telefonoController,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 16),
FluxTextField(
label: 'Email',
icon: Icons.alternate_email_outlined,
controller: _emailController,
),
const SizedBox(height: 16),
FluxTextField(
label: 'Note',
icon: Icons.notes_outlined,
controller: _noteController,
minLines: 3,
),
const SizedBox(height: 8),
SwitchListTile(
title: const Text('Non disturbare'),
value: _nonDisturbare,
onChanged: (v) => setState(() => _nonDisturbare = v),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _submit,
child: Text(widget.customer == null ? 'SALVA' : 'AGGIORNA'),
),
),
],
),
),
);
}
}

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