79 Commits

Author SHA1 Message Date
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
184 changed files with 11060 additions and 3127 deletions

View File

@@ -0,0 +1,99 @@
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
- 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"
# 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"

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"

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

BIN
assets/icon/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

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

@@ -543,7 +543,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 +600,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

@@ -121,6 +121,8 @@ class SessionCubit extends Cubit<SessionState> {
await _prefs.setString(_lastStoreKey, activeStore.id!); await _prefs.setString(_lastStoreKey, activeStore.id!);
} }
setIsSingleUserMode(_prefs.getBool('isSingleUserMode') ?? false);
// 4. BENVENUTO A BORDO // 4. BENVENUTO A BORDO
emit( emit(
state.copyWith( state.copyWith(

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,26 @@
class Tables {
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 tickets = 'tickets';
static const String trackings = 'trackings';
}
const String resetPasswordUrl =
'https://flux-web-invite.marco-6ba.workers.dev/';

View File

@@ -1,97 +1,370 @@
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),
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: context.l10n.commonDashboard,
icon: Icons.dashboard_outlined,
routeName: Routes.home, // <--- Usiamo la tua costante!
pathToCheck:
'/', // Il path da controllare per colorarlo
isCollapsed: effectivelyCollapsed,
),
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 = widget.currentPath.startsWith(basePathToCheck);
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

@@ -12,35 +12,51 @@ 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/company/bloc/company_settings_cubit.dart'; import 'package:flux/features/company/bloc/company_settings_cubit.dart';
import 'package:flux/features/company/ui/company_settings_screen.dart'; import 'package:flux/features/company/ui/company_settings_screen.dart';
import 'package:flux/features/customers/blocs/customers_cubit.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/customers_content.dart'; import 'package:flux/features/customers/ui/customer_form_screen.dart';
import 'package:flux/features/customers/ui/customers_list_screen.dart';
import 'package:flux/features/home/dashboard_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/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/attachments/blocs/attachments_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/blocs/operation_form_cubit.dart';
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/operations/ui/operation_form_screen.dart'; import 'package:flux/features/operations/ui/operation_form_screen.dart';
import 'package:flux/features/operations/ui/operation_list_screen.dart'; import 'package:flux/features/operations/ui/operation_list_screen.dart';
import 'package:flux/features/settings/settings_screen.dart'; import 'package:flux/features/settings/settings_screen.dart';
import 'package:flux/features/settings/theme_settings_view.dart'; import 'package:flux/features/settings/theme_settings_view.dart';
import 'package:flux/features/tasks/blocs/task_form_cubit.dart';
import 'package:flux/features/tasks/blocs/task_list_cubit.dart';
import 'package:flux/features/tasks/models/task_model.dart';
import 'package:flux/features/tasks/ui/task_form_screen.dart';
import 'package:flux/features/tasks/ui/task_list_screen.dart';
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart'; import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart';
import 'package:flux/features/tickets/models/ticket_model.dart'; import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/tickets/ui/ticket_form_screen.dart'; import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
import 'package:flux/features/tickets/ui/ticket_list_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';
@@ -118,94 +134,158 @@ class AppRouter {
ShellRoute( ShellRoute(
builder: (context, state, child) => AppShell(child: child), builder: (context, state, child) => AppShell(child: child),
routes: [ routes: [
// ==========================================
// 1. DASHBOARD // 1. DASHBOARD
// ==========================================
GoRoute( GoRoute(
path: '/', path: '/',
name: Routes.home, name: Routes.home,
builder: (context, state) => const HomeScreen(), builder: (context, state) {
return MultiBlocProvider(
providers: [
BlocProvider<DashboardTaskListCubit>(
create: (context) => DashboardTaskListCubit(),
),
],
child: HomeScreen(),
);
},
), ),
// ==========================================
// 2. HUB ANAGRAFICHE E SOTTO-ROTTE // 2. HUB ANAGRAFICHE E SOTTO-ROTTE
// ==========================================
GoRoute( GoRoute(
path: '/master-data', path: '/master-data',
name: Routes.masterData, name: Routes.masterData,
builder: (context, state) => const MasterDataHubScreen(), builder: (context, state) => const MasterDataHubScreen(),
routes: [ routes: [
GoRoute( GoRoute(
path: 'products', // Diventa /master-data/products path:
'customers', // Niente slash iniziale per le sottorotte! -> /master-data/customers
name: Routes.customers,
builder: (context, state) => const CustomersListScreen(),
),
GoRoute(
path: 'providers', // -> /master-data/providers
name: Routes.providers,
builder: (context, state) => const ProviderListScreen(),
),
GoRoute(
path: 'products', // -> /master-data/products
name: Routes.products, name: Routes.products,
builder: (context, state) { builder: (context, state) {
context.read<ProductsCubit>().refreshCubit(); context.read<ProductsCubit>().refreshCubit();
return const ProductsScreen(); return const ProductsScreen();
}, },
), ),
GoRoute( GoRoute(
path: 'company-settings', path: 'staff', // -> /master-data/staff
name: Routes.staff,
builder: (context, state) => const StaffScreen(),
),
GoRoute(
path:
'stores', // Sistemata l'inversione path/name -> /master-data/stores
name: Routes.stores,
builder: (context, state) {
context.read<ProviderListCubit>().loadAllProviders();
context.read<StoreCubit>().loadStores();
return const StoresScreen();
},
),
GoRoute(
path: 'company-settings', // -> /master-data/company-settings
name: Routes.companySettings, name: Routes.companySettings,
builder: (context, state) => BlocProvider( builder: (context, state) => BlocProvider(
create: (context) => CompanySettingsCubit(), create: (context) => CompanySettingsCubit(),
child: const CompanySettingsScreen(), child: const CompanySettingsScreen(),
), ),
), ),
GoRoute(
path: 'staff',
name: Routes.staff, // Diventa /master-data/staff
builder: (context, state) => const StaffScreen(),
),
GoRoute(
path: Routes.stores,
name: 'stores', // Diventa /master-data/stores
builder: (context, state) => const StoresScreen(),
),
GoRoute(
path: 'providers',
name: Routes.providers, // Diventa /master-data/providers
builder: (context, state) =>
const ProvidersMasterDataScreen(),
),
], ],
), ),
// ==========================================
// 3. IMPOSTAZIONI // 3. IMPOSTAZIONI
// ==========================================
GoRoute( GoRoute(
path: '/settings', path: '/settings',
name: Routes.settings, name: Routes.settings,
builder: (context, state) => const SettingsScreen(), builder: (context, state) => const SettingsScreen(),
routes: [ routes: [
GoRoute( GoRoute(
path: 'themeSettings', path: 'themeSettings', // -> /settings/themeSettings
name: Routes.themeSettings, name: Routes.themeSettings,
builder: (context, state) => const ThemeSettingsView(), builder: (context, state) => const ThemeSettingsView(),
), ),
], ],
), ),
// ==========================================
// 4. SCHERMATE PRINCIPALI EXTRA NELLA SHELL
// (Accessibili ad es. dalla dashboard, mantengono la sidebar)
// ==========================================
GoRoute( GoRoute(
path: '/operations', path: '/operations',
name: Routes.operations, name: Routes.operations,
builder: (context, state) => BlocProvider( builder: (context, state) => const OperationListScreen(),
create: (context) => OperationListCubit(),
child: const OperationListScreen(),
),
),
GoRoute(
path: '/customers',
name: Routes.customers,
builder: (context, state) =>
const CustomersContent(), // O come si chiama il tuo widget della lista!
), ),
GoRoute( GoRoute(
path: '/tickets', path: '/tickets',
name: Routes.tickets, name: Routes.tickets,
builder: (context, state) => BlocProvider( builder: (context, state) => const TicketListScreen(),
create: (context) => TicketListCubit(), ),
child: 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(
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( GoRoute(
// Il path sarà es. /tickets/form/123 oppure /tickets/form/new // Il path sarà es. /tickets/form/123 oppure /tickets/form/new
path: '/tickets/form/:id', path: '/tickets/form/:id',
@@ -230,7 +310,13 @@ class AppRouter {
} else { } else {
realTicketId = pathId; realTicketId = pathId;
} }
context.read<CustomersCubit>().loadCustomers(); if (realTicketId != null) {
context.read<TrackingCubit>().loadTrackings(
realTicketId,
TrackingParentType.ticket,
);
}
context.read<CustomersListCubit>().loadCustomers();
context.read<ProductsCubit>().loadModels(); context.read<ProductsCubit>().loadModels();
context.read<ProductsCubit>().loadBrands(); context.read<ProductsCubit>().loadBrands();
@@ -258,14 +344,44 @@ class AppRouter {
); );
}, },
), ),
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( GoRoute(
path: '/upload-success', path: '/upload-success',
name: Routes.uploadSuccess, name: Routes.uploadSuccess,
builder: (context, state) => const UploadSuccessScreen(), builder: (context, state) => const UploadSuccessScreen(),
), ),
GoRoute( GoRoute(
path: '/customer/form/:id', path: '/customer/details/:id',
name: 'customer-form', 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(
@@ -277,6 +393,31 @@ class AppRouter {
); );
}, },
), ),
GoRoute(
path: '/customer/form/:id',
name: Routes.customerForm,
builder: (context, state) {
final String pathId = state.pathParameters['id'] ?? 'new';
final String? realCustomerId;
if (pathId == 'new') {
realCustomerId = null;
} else {
realCustomerId = pathId;
}
final customer = state.extra as CustomerModel?;
return BlocProvider(
create: (context) => CustomerFormCubit(
existingCustomer: customer,
customerId: realCustomerId,
),
child: CustomerFormScreen(
customer: customer,
customerId: realCustomerId,
),
);
},
),
GoRoute( GoRoute(
path: '/operations/form/:id', path: '/operations/form/:id',
@@ -304,10 +445,8 @@ class AppRouter {
.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();
return MultiBlocProvider( return MultiBlocProvider(
@@ -365,6 +504,61 @@ class AppRouter {
); );
}, },
), ),
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;
}
final allStaffList = context.read<StaffCubit>().state.allStaff;
// Creiamo il BLoC "al volo" solo per questa schermata
return MultiBlocProvider(
providers: [
BlocProvider<TaskFormCubit>(
create: (context) => TaskFormCubit(
globalStaff: allStaffList,
initialTask: task,
initialTaskId: realTaskId,
),
),
],
child: TaskFormScreen(),
);
},
),
], ],
); );
} }

View File

@@ -9,6 +9,7 @@ class Routes {
static const String staff = 'staff'; static const String staff = 'staff';
static const String stores = 'stores'; static const String stores = 'stores';
static const String providers = 'providers'; static const String providers = 'providers';
static const String providerForm = 'provider-form';
static const String settings = 'settings'; static const String settings = 'settings';
static const String themeSettings = 'themeSettings'; static const String themeSettings = 'themeSettings';
static const String operations = 'operations'; static const String operations = 'operations';
@@ -18,5 +19,11 @@ class Routes {
static const String operationForm = 'operation-form'; static const String operationForm = 'operation-form';
static const String uploadSuccess = 'upload-success'; static const String uploadSuccess = 'upload-success';
static const String customerForm = 'customer-form'; static const String customerForm = 'customer-form';
static const String customerDetails = 'customer-details';
static const String upload = 'upload'; 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';
} }

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,67 @@
import 'package:flutter/foundation.dart';
import 'dart:io' show Platform;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class VersionCheckService {
final _supabase = Supabase.instance.client;
/// Controlla se l'app corrente deve essere bloccata o aggiornata.
/// Ritorna il link di download se l'aggiornamento è obbligatorio, altrimenti null.
Future<String?> checkForceUpdate() async {
try {
// 1. Determiniamo la piattaforma corrente
String platformKey = 'web';
if (!kIsWeb) {
if (Platform.isAndroid) platformKey = 'android';
if (Platform.isWindows) platformKey = 'windows';
}
// 2. Recuperiamo la configurazione minima da Supabase
final data = await _supabase
.from('app_config')
.select()
.eq('platform', platformKey)
.maybeSingle();
if (data == null) return null;
final String minVersion = data['min_version'];
final String downloadUrl = data['download_url'];
// 3. Recuperiamo la versione attuale dell'app dal pubspec.yaml
final packageInfo = await PackageInfo.fromPlatform();
final String currentVersion = packageInfo.version;
// 4. Confronto matematico semantico (es. 1.2.3 vs 1.1.9)
if (_isVersionLower(currentVersion, minVersion)) {
return downloadUrl; // Aggiornamento obbligatorio richiesto!
}
return null;
} catch (e) {
debugPrint('Errore controllo versione: $e');
return null; // In caso di errore non blocchiamo l'utente
}
}
bool _isVersionLower(String current, String min) {
List<int> currentParts = current
.split('.')
.map((e) => int.tryParse(e) ?? 0)
.toList();
List<int> minParts = min
.split('.')
.map((e) => int.tryParse(e) ?? 0)
.toList();
for (int i = 0; i < 3; i++) {
int currentPart = currentParts.length > i ? currentParts[i] : 0;
int minPart = minParts.length > i ? minParts[i] : 0;
if (currentPart < minPart) return true;
if (currentPart > minPart) return false;
}
return false;
}
}

View File

@@ -21,6 +21,7 @@ class FluxTextField extends StatefulWidget {
final TextCapitalization? textCapitalization; final TextCapitalization? textCapitalization;
final bool? autocorrect; final bool? autocorrect;
final bool? enabled; final bool? enabled;
final Iterable<String>? autofillHints;
const FluxTextField({ const FluxTextField({
super.key, // Usiamo super.key per Flutter moderno super.key, // Usiamo super.key per Flutter moderno
@@ -41,6 +42,7 @@ class FluxTextField extends StatefulWidget {
this.textCapitalization, this.textCapitalization,
this.autocorrect, this.autocorrect,
this.enabled = true, this.enabled = true,
this.autofillHints,
}); });
@override @override
@@ -118,6 +120,7 @@ class _FluxTextFieldState extends State<FluxTextField> {
textCapitalization: widget.textCapitalization ?? TextCapitalization.none, textCapitalization: widget.textCapitalization ?? TextCapitalization.none,
enabled: widget.enabled, enabled: widget.enabled,
autofillHints: widget.autofillHints,
); );
} }
} }

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/extensions.dart'; import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
@@ -72,6 +74,12 @@ class _SetPasswordScreenState extends State<SetPasswordScreen> {
title: Text(context.l10n.setPasswordScreenWelcomeInFlux), title: Text(context.l10n.setPasswordScreenWelcomeInFlux),
automaticallyImplyLeading: automaticallyImplyLeading:
false, // Non può tornare indietro, deve mettere la password! false, // Non può tornare indietro, deve mettere la password!
actions: [
IconButton.filled(
onPressed: () => context.read<SessionCubit>().signOut(),
icon: Icon(Icons.logout),
),
],
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),

View File

@@ -153,7 +153,8 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
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,
); );
} }
@@ -283,7 +284,8 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
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,
); );
} }
@@ -424,7 +426,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
title: const Text( title: const Text(
'Cartella Export (Es. TIM AttachmentRepository)', 'Cartella Export PDF',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
subtitle: Text( subtitle: Text(
@@ -561,8 +563,6 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
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
@@ -661,6 +661,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
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),

View File

@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/routes/routes.dart'; import 'package:flux/core/routes/routes.dart';
import 'package:flux/features/customers/blocs/customers_cubit.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/quick_customer_dialog.dart'; import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
@@ -64,11 +65,17 @@ class SharedCustomerSection extends StatelessWidget {
if (hasCustomer) ...[ if (hasCustomer) ...[
const SizedBox(width: 12), const SizedBox(width: 12),
IconButton( IconButton(
onPressed: () => context.pushNamed( onPressed: () async {
Routes.customerForm, final updatedCustomer = await context.pushNamed(
pathParameters: {'id': customer!.id!}, Routes.customerForm,
extra: customer, pathParameters: {'id': customer!.id!},
), extra: customer,
);
if (updatedCustomer != null &&
updatedCustomer is CustomerModel) {
onCustomerSelected(updatedCustomer);
}
},
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
), ),
], ],
@@ -253,7 +260,7 @@ class SharedCustomerSection extends StatelessWidget {
), ),
onChanged: (query) { onChanged: (query) {
currentSearchQuery = query; currentSearchQuery = query;
context.read<CustomersCubit>().searchCustomers(query); context.read<CustomersListCubit>().searchCustomers(query);
}, },
), ),
), ),
@@ -272,10 +279,13 @@ class SharedCustomerSection extends StatelessWidget {
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return BlocProvider.value( return BlocProvider.value(
value: context.read<CustomersCubit>(), value: context.read<CustomersListCubit>(),
child: QuickCustomerDialog( child: BlocProvider<CustomerFormCubit>(
initialQuery: create: (context) => CustomerFormCubit(),
currentSearchQuery, // <-- Passiamo quello che ha digitato! child: QuickCustomerDialog(
initialQuery:
currentSearchQuery, // <-- Passiamo quello che ha digitato!
),
), ),
); );
}, },
@@ -297,9 +307,9 @@ class SharedCustomerSection extends StatelessWidget {
const Divider(), const Divider(),
// Lista Clienti dal Bloc // Lista Clienti dal Bloc
Expanded( Expanded(
child: BlocBuilder<CustomersCubit, CustomersState>( child: BlocBuilder<CustomersListCubit, CustomersListState>(
builder: (context, state) { builder: (context, state) {
if (state.status == CustomersStatus.loading) { if (state.status == CustomersListStatus.loading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (state.customers.isEmpty) { if (state.customers.isEmpty) {

View File

@@ -83,6 +83,8 @@ class SharedModelSection extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField( child: TextField(
autofocus: true,
textInputAction: TextInputAction.search,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Cerca modello (es. iPhone 15...)', hintText: 'Cerca modello (es. iPhone 15...)',
prefixIcon: const Icon(Icons.search), prefixIcon: const Icon(Icons.search),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/widgets/image_upload/blocs/image_upload_cubit.dart';
import 'package:flux/core/widgets/image_upload/ui/image_upload_screen.dart'; import 'package:flux/core/widgets/image_upload/ui/image_upload_screen.dart';
import 'package:flux/core/widgets/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/blocs/attachments_bloc.dart';
@@ -98,13 +99,16 @@ class SharedFilesSection extends StatelessWidget {
MaterialPageRoute( MaterialPageRoute(
builder: (_) => BlocProvider.value( builder: (_) => BlocProvider.value(
value: bloc, value: bloc,
child: ImageUploadScreen( child: BlocProvider<ImageUploadCubit>(
title: titleNameForUpload, create: (context) => ImageUploadCubit(),
companyId: GetIt.I child: ImageUploadScreen(
.get<SessionCubit>() title: titleNameForUpload,
.state companyId: GetIt.I
.company! .get<SessionCubit>()
.id!, .state
.company!
.id!,
),
), ),
), ),
), ),

View File

@@ -41,6 +41,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
add(LoadAttachmentsEvent(parentId: parentId)); add(LoadAttachmentsEvent(parentId: parentId));
} }
} }
FutureOr<void> _onParentEntitySaved( FutureOr<void> _onParentEntitySaved(
ParentEntitySavedEvent event, ParentEntitySavedEvent event,
Emitter<AttachmentsState> emit, Emitter<AttachmentsState> emit,
@@ -67,6 +68,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
parentType: state.parentType, parentType: state.parentType,
pickedFile: fakePlatformFile, pickedFile: fakePlatformFile,
companyId: companyId!, companyId: companyId!,
bucket: _getBucketForParentType,
); );
}).toList(); }).toList();
@@ -156,6 +158,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
parentType: state.parentType, parentType: state.parentType,
pickedFile: file, pickedFile: file,
companyId: companyId!, companyId: companyId!,
bucket: _getBucketForParentType,
); );
}).toList(); }).toList();
@@ -192,6 +195,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
parentType: state.parentType, parentType: state.parentType,
pickedFile: file, pickedFile: file,
companyId: event.companyId, companyId: event.companyId,
bucket: _getBucketForParentType,
), ),
); );
} }
@@ -218,6 +222,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
parentType: state.parentType, parentType: state.parentType,
pickedFile: fakePlatformFile, pickedFile: fakePlatformFile,
companyId: event.companyId, companyId: event.companyId,
bucket: _getBucketForParentType,
), ),
); );
} }
@@ -242,6 +247,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
await _repository.deleteFiles( await _repository.deleteFiles(
files: state.selectedFiles, files: state.selectedFiles,
currentContextType: state.parentType, currentContextType: state.parentType,
bucket: _getBucketForParentType,
); );
emit(state.copyWith(status: AttachmentsStatus.ready, selectedFiles: [])); emit(state.copyWith(status: AttachmentsStatus.ready, selectedFiles: []));
} catch (e) { } catch (e) {
@@ -298,6 +304,10 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
return file.copyWith(ticketId: event.targetId); return file.copyWith(ticketId: event.targetId);
case AttachmentParentType.operation: case AttachmentParentType.operation:
return file.copyWith(operationId: event.targetId); 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; return file;
@@ -386,4 +396,19 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
emit(state.copyWith(localFiles: updatedLocalFiles)); 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

@@ -5,7 +5,9 @@ enum AttachmentsStatus { initial, loading, ready, uploading, success, failure }
enum AttachmentParentType { enum AttachmentParentType {
operation('operation_id'), operation('operation_id'),
ticket('ticket_id'), ticket('ticket_id'),
customer('customer_id'); customer('customer_id'),
shippingDocument('shipping_document_id'),
note('note_id');
final String dbColumn; final String dbColumn;
const AttachmentParentType(this.dbColumn); const AttachmentParentType(this.dbColumn);

View File

@@ -1,20 +1,29 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:file_picker/file_picker.dart'; 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: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'; 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;
static const String _bucketName = 'documents';
static const String _tableName =
'attachment'; // Cambia col vero nome della tua tabella se diverso!
/// 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 {
final Uint8List bytes = await _supabase.storage final Uint8List bytes = await _supabase.storage
.from(_bucketName) .from(bucket.value)
.download(storagePath); .download(storagePath);
return bytes; return bytes;
} catch (e) { } catch (e) {
@@ -31,6 +40,10 @@ class AttachmentsRepository {
return 'ticket_id'; return 'ticket_id';
case AttachmentParentType.customer: case AttachmentParentType.customer:
return 'customer_id'; return 'customer_id';
case AttachmentParentType.shippingDocument:
return 'shipping_document_id';
case AttachmentParentType.note:
return 'note_id';
} }
} }
@@ -42,7 +55,7 @@ class AttachmentsRepository {
final columnName = _getColumnNameForParent(parentType); final columnName = _getColumnNameForParent(parentType);
return _supabase return _supabase
.from(_tableName) .from(Tables.attachments)
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.eq(columnName, parentId) .eq(columnName, parentId)
.map( .map(
@@ -55,41 +68,70 @@ class AttachmentsRepository {
Future<void> uploadAndRegisterFile({ Future<void> uploadAndRegisterFile({
required String parentId, required String parentId,
required AttachmentParentType parentType, required AttachmentParentType parentType,
required PlatformFile pickedFile,
required String companyId, required String companyId,
required Bucket bucket,
PlatformFile? pickedFile, // Ora è opzionale
Uint8List? rawBytes, // Alternativa: bytes grezzi
String? rawFileName, // Alternativa: nome del file
}) async { }) 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 { try {
if (pickedFile.bytes == null) { // 1. Normalizziamo i dati in base a cosa ci è stato passato
throw Exception( final Uint8List finalBytes;
"I bytes del file sono vuoti! Ricarica la pagina senza cache.", 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
} }
final extension = pickedFile.extension ?? pickedFile.name.split('.').last; // 2. Estraiamo l'estensione e puliamo il nome
final cleanName = pickedFile.name final extension = finalFileName.contains('.')
? finalFileName.split('.').last
: ''; // Fallback se il file non ha estensione
final cleanName = finalFileName
.replaceAll(RegExp(r'[^\w\s\.-]'), '') .replaceAll(RegExp(r'[^\w\s\.-]'), '')
.replaceAll(' ', '_'); .replaceAll(' ', '_');
// Creiamo un path ordinato: idAzienda/tipoEntita/idEntita/timestamp_nomefile // 3. Creiamo un path ordinato: idAzienda/tipoEntita/idEntita/timestamp_nomefile
final timestamp = DateTime.now().millisecondsSinceEpoch; final timestamp = DateTime.now().millisecondsSinceEpoch;
final storagePath = final storagePath =
'$companyId/${parentType.name}/$parentId/${timestamp}_$cleanName'; '$companyId/${parentType.name}/$parentId/${timestamp}_$cleanName';
// 1. Upload su Supabase Storage // 4. Upload su Supabase Storage
await _supabase.storage await _supabase.storage
.from(_bucketName) .from(bucket.value)
.uploadBinary( .uploadBinary(
storagePath, storagePath,
pickedFile.bytes!, finalBytes,
fileOptions: FileOptions(contentType: _guessContentType(extension)), fileOptions: FileOptions(contentType: _guessContentType(extension)),
); );
// 2. Creiamo la mappa per il DB dinamicamente // 5. Creiamo la mappa per il DB dinamicamente
final Map<String, dynamic> insertData = { final Map<String, dynamic> insertData = {
'company_id': companyId, 'company_id': companyId,
'name': pickedFile.name.replaceAll('.$extension', ''), 'name': finalFileName.replaceAll('.$extension', ''),
'extension': extension, 'extension': extension,
'file_size': pickedFile.size, 'file_size': finalFileSize,
'storage_path': storagePath, 'storage_path': storagePath,
}; };
@@ -97,8 +139,8 @@ class AttachmentsRepository {
final columnName = _getColumnNameForParent(parentType); final columnName = _getColumnNameForParent(parentType);
insertData[columnName] = parentId; insertData[columnName] = parentId;
// 3. Salviamo su Postgres // 6. Salviamo su Postgres
await _supabase.from(_tableName).insert(insertData); await _supabase.from(Tables.attachments).insert(insertData);
} catch (e) { } catch (e) {
throw Exception("Errore caricamento: $e"); throw Exception("Errore caricamento: $e");
} }
@@ -108,6 +150,7 @@ class AttachmentsRepository {
Future<void> deleteFiles({ Future<void> deleteFiles({
required List<AttachmentModel> files, required List<AttachmentModel> files,
required AttachmentParentType currentContextType, required AttachmentParentType currentContextType,
required Bucket bucket,
}) async { }) async {
if (files.isEmpty) return; if (files.isEmpty) return;
@@ -120,6 +163,7 @@ class AttachmentsRepository {
AttachmentParentType.operation: file.operationId, AttachmentParentType.operation: file.operationId,
AttachmentParentType.ticket: file.ticketId, AttachmentParentType.ticket: file.ticketId,
AttachmentParentType.customer: file.customerId, AttachmentParentType.customer: file.customerId,
AttachmentParentType.shippingDocument: file.shippingDocumentId,
}; };
// 2. Simuliamo la rimozione del collegamento per il contesto attuale // 2. Simuliamo la rimozione del collegamento per il contesto attuale
@@ -134,15 +178,15 @@ class AttachmentsRepository {
// A. Ci sono ancora altre entità che usano questo file! // A. Ci sono ancora altre entità che usano questo file!
// Scolleghiamolo SOLO dal contesto attuale mettendo a NULL la sua colonna // Scolleghiamolo SOLO dal contesto attuale mettendo a NULL la sua colonna
await _supabase await _supabase
.from(_tableName) .from(Tables.attachments)
.update({currentContextType.dbColumn: null}) .update({currentContextType.dbColumn: null})
.eq('id', file.id!); .eq('id', file.id!);
} else { } else {
// B. Nessuno usa più questo file! ELIMINAZIONE FISICA TOTALE. // B. Nessuno usa più questo file! ELIMINAZIONE FISICA TOTALE.
await _supabase.from(_tableName).delete().eq('id', file.id!); await _supabase.from(Tables.attachments).delete().eq('id', file.id!);
if (file.storagePath != null) { if (file.storagePath != null) {
await _supabase.storage.from(_bucketName).remove([ await _supabase.storage.from(bucket.value).remove([
file.storagePath!, file.storagePath!,
]); ]);
} }
@@ -157,7 +201,7 @@ class AttachmentsRepository {
Future<void> renameAttachment(String fileId, String newName) async { Future<void> renameAttachment(String fileId, String newName) async {
try { try {
await _supabase await _supabase
.from(_tableName) .from(Tables.attachments)
.update({'name': newName}) .update({'name': newName})
.eq('id', fileId); .eq('id', fileId);
} catch (e) { } catch (e) {
@@ -174,7 +218,7 @@ class AttachmentsRepository {
try { try {
// Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta // Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta
await _supabase await _supabase
.from(_tableName) .from(Tables.attachments)
.update({targetType.dbColumn: targetId}) .update({targetType.dbColumn: targetId})
.eq('id', fileId); .eq('id', fileId);
} catch (e) { } catch (e) {

View File

@@ -8,6 +8,8 @@ class AttachmentModel extends Equatable {
final String? customerId; final String? customerId;
final String? operationId; final String? operationId;
final String? ticketId; 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;
@@ -21,6 +23,8 @@ class AttachmentModel extends Equatable {
this.customerId, this.customerId,
this.operationId, this.operationId,
this.ticketId, this.ticketId,
this.shippingDocumentId,
this.noteId,
required this.name, required this.name,
required this.extension, required this.extension,
this.storagePath, this.storagePath,
@@ -36,6 +40,8 @@ class AttachmentModel extends Equatable {
customerId, customerId,
operationId, operationId,
ticketId, ticketId,
shippingDocumentId,
noteId,
name, name,
extension, extension,
storagePath, storagePath,
@@ -63,6 +69,8 @@ class AttachmentModel extends Equatable {
String? customerId, String? customerId,
String? operationId, String? operationId,
String? ticketId, String? ticketId,
String? shippingDocumentId,
String? noteId,
String? name, String? name,
String? extension, String? extension,
String? storagePath, String? storagePath,
@@ -75,6 +83,8 @@ class AttachmentModel extends Equatable {
customerId: customerId ?? this.customerId, customerId: customerId ?? this.customerId,
operationId: operationId ?? this.operationId, operationId: operationId ?? this.operationId,
ticketId: ticketId ?? this.ticketId, 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,
@@ -92,6 +102,8 @@ class AttachmentModel extends Equatable {
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?, 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?,
@@ -111,6 +123,8 @@ class AttachmentModel extends Equatable {
'customer_id': customerId, 'customer_id': customerId,
'operation_id': operationId, 'operation_id': operationId,
'ticket_id': ticketId, '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,7 +1,7 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/data/constants.dart'; import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/core/utils/app_message.dart'; import 'package:flux/core/utils/app_message.dart';
import 'package: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';
@@ -16,7 +16,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 +28,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 +47,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 +56,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 +81,7 @@ class AuthCubit extends Cubit<AuthState> {
errorMessage: "Errore imprevisto: $e", errorMessage: "Errore imprevisto: $e",
), ),
); );
return false; // <-- Il login è fallito
} }
} }

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,133 @@ 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),
const SizedBox(height: 40),
// --- CAMPI INPUT --- // --- CAMPI INPUT ---
FluxTextField( FluxTextField(
label: context.l10n.authScreenBusinessEmail, label: context.l10n.authScreenBusinessEmail,
icon: Icons.email_outlined, icon: Icons.email_outlined,
controller: _emailController, controller: _emailController,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
), autofillHints: const [
const SizedBox(height: 20), AutofillHints.email,
FluxTextField( AutofillHints.username,
label: 'Password', ],
icon: Icons.lock_outline, ),
isPassword: true, // Magia del FluxTextField! const SizedBox(height: 20),
controller: _passwordController, FluxTextField(
onSubmitted: (_) => label: 'Password',
_submit(), // Se lo supporti nel tuo widget custom icon: Icons.lock_outline,
), isPassword: true, // Magia del FluxTextField!
controller: _passwordController,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) =>
_submit(), // Se lo supporti nel tuo widget custom
),
const SizedBox(height: 40), const SizedBox(height: 40),
// --- BOTTONE PRINCIPALE --- // --- BOTTONE PRINCIPALE ---
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 56, height: 56,
child: ElevatedButton( child: ElevatedButton(
onPressed: isLoading ? null : _submit, onPressed: isLoading ? null : _submit,
child: isLoading child: isLoading
? const SizedBox( ? const SizedBox(
height: 24, height: 24,
width: 24, width: 24,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: Colors.white, color: Colors.white,
),
)
: Text(
state.isLoginMode
? context.l10n.authScreenLogin
: context.l10n.authScreenSignUp,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
), ),
) ),
: Text( ),
state.isLoginMode
? context.l10n.authScreenLogin // --- SWITCH LOGIN/SIGNUP ---
: context.l10n.authScreenSignUp, const SizedBox(height: 24),
style: const TextStyle( TextButton(
onPressed: isLoading
? null
: () => context.read<AuthCubit>().toggleMode(),
child: RichText(
text: TextSpan(
text: state.isLoginMode
? context.l10n.authScreenDontHaveAccount
: 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, fontWeight: FontWeight.bold,
), ),
), ),
), ],
),
// --- SWITCH LOGIN/SIGNUP ---
const SizedBox(height: 24),
TextButton(
onPressed: isLoading
? null
: () => context.read<AuthCubit>().toggleMode(),
child: RichText(
text: TextSpan(
text: state.isLoginMode
? context.l10n.authScreenDontHaveAccount
: 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,
), ),
), ),
), ),
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

@@ -1,5 +1,6 @@
import 'dart:typed_data'; 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';
@@ -10,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();
@@ -26,7 +27,7 @@ class CompanyRepository {
Future<CompanyModel> updateCompany(CompanyModel company) async { Future<CompanyModel> updateCompany(CompanyModel company) async {
try { try {
final response = await _supabase final response = await _supabase
.from('company') .from(Tables.companies)
.update(company.toMap()) .update(company.toMap())
.eq('id', company.id!) .eq('id', company.id!)
.select() .select()
@@ -83,7 +84,7 @@ class CompanyRepository {
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

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

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

View File

@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/core/widgets/shared_forms/attachments_section.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
import 'package:flux/features/customers/models/customer_model.dart'; // Uso il tuo widget!
class CustomerFormScreen extends StatefulWidget {
final CustomerModel? customer;
final String? customerId;
const CustomerFormScreen({super.key, this.customer, this.customerId});
@override
State<CustomerFormScreen> createState() => _CustomerFormScreenState();
}
class _CustomerFormScreenState extends State<CustomerFormScreen> {
final _formKey = GlobalKey<FormState>();
// Controller inizializzati con i dati del cliente (se presenti)
final TextEditingController _nomeController = TextEditingController();
final TextEditingController _telefonoController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _noteController = TextEditingController();
bool _isInitialized = false;
@override
void initState() {
super.initState();
// 1. Lanciamo l'inizializzazione (che se è sincrona, finirà istantaneamente)
context.read<CustomerFormCubit>().initForm(
customerId: widget.customerId,
existingCustomer: widget.customer,
);
// 2. Leggiamo lo stato SUBITO DOPO. Se è già ready, non aspettiamo il listener!
final currentState = context.read<CustomerFormCubit>().state;
if (currentState.status == CustomerFormStatus.ready && !_isInitialized) {
_syncTextControllers(currentState.customer);
}
}
@override
void dispose() {
_nomeController.dispose();
_telefonoController.dispose();
_emailController.dispose();
_noteController.dispose();
super.dispose();
}
void _syncTextControllers(CustomerModel customer) {
if (_nomeController.text.isEmpty) {
_nomeController.text = customer.name;
}
if (_telefonoController.text.isEmpty) {
_telefonoController.text = customer.phoneNumber;
}
if (_emailController.text.isEmpty) {
_emailController.text = customer.email;
}
if (_noteController.text.isEmpty) {
_noteController.text = customer.note;
}
_isInitialized = true;
}
void _flushControllersToCubit() {
context.read<CustomerFormCubit>().updateFields(
name: _nomeController.text.trim(),
phoneNumber: _telefonoController.text.trim(),
email: _emailController.text.trim(),
note: _noteController.text.trim(),
);
}
void _saveCustomer() {
if (_formKey.currentState!.validate()) {
_flushControllersToCubit();
context.read<CustomerFormCubit>().saveCustomer();
}
}
@override
Widget build(BuildContext context) {
return BlocConsumer<CustomerFormCubit, CustomerFormState>(
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
if (state.status == CustomerFormStatus.ready && !_isInitialized) {
_syncTextControllers(state.customer);
}
if (state.status == CustomerFormStatus.success) {
Navigator.of(context).pop(state.customer);
} else if (state.status == CustomerFormStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage ?? 'Errore sconosciuto')),
);
}
},
builder: (context, state) {
return Scaffold(
appBar: AppBar(
title: Text(
state.customer.id == null
? 'Nuovo Cliente'
: 'Modifica ${state.customer.name}',
),
actions: [],
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
ChoiceChip(
label: const Text('Privato (Domestico)'),
selected: !state.customer.isBusiness,
selectedColor: Colors.blue.withValues(alpha: 0.2),
checkmarkColor: Colors.blue.shade700,
onSelected: (selected) {
if (selected) {
context.read<CustomerFormCubit>().updateFields(
isBusiness: false,
);
}
},
),
const SizedBox(width: 12),
ChoiceChip(
label: const Text('Business (P.IVA)'),
selected: state.customer.isBusiness,
selectedColor: Colors.orange.withValues(alpha: 0.2),
checkmarkColor: Colors.orange.shade700,
onSelected: (selected) {
if (selected) {
context.read<CustomerFormCubit>().updateFields(
isBusiness: true,
);
}
},
),
],
),
const Divider(height: 32),
FluxTextField(
label: 'Nome Completo',
autoFocus: true,
icon: Icons.person_outline,
controller: _nomeController,
keyboardType: TextInputType.name,
),
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,
keyboardType: TextInputType.emailAddress,
),
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: state.customer.doNotDisturb,
onChanged: (v) => context
.read<CustomerFormCubit>()
.updateDoNotDisturb(v),
),
const Divider(height: 32),
BlocProvider<AttachmentsBloc>(
create: (context) => AttachmentsBloc(
parentType: AttachmentParentType.customer,
parentId: state.customer.id,
),
child: SharedAttachmentsSection(
parentType: AttachmentParentType.customer,
parentId: state.customer.id,
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _saveCustomer,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
),
child: Text(
widget.customer == null ? 'SALVA' : 'AGGIORNA',
),
),
),
],
),
),
),
),
);
},
);
}
}

View File

@@ -3,19 +3,18 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/routes.dart'; import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/customers/blocs/customers_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_form.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class CustomersContent extends StatefulWidget { class CustomersListScreen extends StatefulWidget {
const CustomersContent({super.key}); const CustomersListScreen({super.key});
@override @override
State<CustomersContent> createState() => _CustomersContentState(); State<CustomersListScreen> createState() => _CustomersListScreenState();
} }
class _CustomersContentState extends State<CustomersContent> { class _CustomersListScreenState extends State<CustomersListScreen> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
@override @override
@@ -27,14 +26,14 @@ class _CustomersContentState extends State<CustomersContent> {
void _loadInitialCustomers() { void _loadInitialCustomers() {
final companyId = context.read<SessionCubit>().state.company?.id; final companyId = context.read<SessionCubit>().state.company?.id;
if (companyId != null) { if (companyId != null) {
context.read<CustomersCubit>().loadCustomers(); context.read<CustomersListCubit>().loadCustomers();
} }
} }
void _onSearch(String query) { void _onSearch(String query) {
final companyId = context.read<SessionCubit>().state.company?.id; final companyId = context.read<SessionCubit>().state.company?.id;
if (companyId != null) { if (companyId != null) {
context.read<CustomersCubit>().searchCustomers(query); context.read<CustomersListCubit>().searchCustomers(query);
} }
} }
@@ -53,7 +52,12 @@ class _CustomersContentState extends State<CustomersContent> {
Padding( Padding(
padding: const EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: 16),
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () => openCustomerForm(context: context), onPressed: () {
context.pushNamed(
Routes.customerForm,
pathParameters: {'id': 'new'},
);
},
icon: const Icon(Icons.person_add_alt_1_rounded, size: 20), icon: const Icon(Icons.person_add_alt_1_rounded, size: 20),
label: const Text('NUOVO'), label: const Text('NUOVO'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -87,9 +91,9 @@ class _CustomersContentState extends State<CustomersContent> {
// LISTA CLIENTI // LISTA CLIENTI
Expanded( Expanded(
child: BlocBuilder<CustomersCubit, CustomersState>( child: BlocBuilder<CustomersListCubit, CustomersListState>(
builder: (context, state) { builder: (context, state) {
if (state.status == CustomersStatus.loading && if (state.status == CustomersListStatus.loading &&
state.customers.isEmpty) { state.customers.isEmpty) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -111,7 +115,7 @@ class _CustomersContentState extends State<CustomersContent> {
return _CustomerTile( return _CustomerTile(
customer: customer, customer: customer,
onTap: () => context.pushNamed( onTap: () => context.pushNamed(
Routes.customerForm, Routes.customerDetails,
pathParameters: {'id': customer.id!}, pathParameters: {'id': customer.id!},
extra: customer, extra: customer,
), ),
@@ -214,8 +218,16 @@ class _CustomerTile extends StatelessWidget {
), ),
), ),
trailing: IconButton( trailing: IconButton(
onPressed: () => onPressed: () async {
openCustomerForm(context: context, customer: customer), final CustomersListCubit customersCubit = context
.read<CustomersListCubit>();
await context.pushNamed(
Routes.customerForm,
pathParameters: {'id': customer.id!},
extra: customer,
);
customersCubit.loadCustomers();
},
icon: Icon(Icons.edit_note_rounded, color: context.accent), icon: Icon(Icons.edit_note_rounded, color: context.accent),
), ),
), ),
@@ -224,7 +236,7 @@ class _CustomerTile extends StatelessWidget {
} }
/// Funzione unica per gestire Creazione e Modifica /// Funzione unica per gestire Creazione e Modifica
void openCustomerForm({ /* void openCustomerForm({
CustomerModel? customer, CustomerModel? customer,
required BuildContext context, required BuildContext context,
}) { }) {
@@ -257,4 +269,4 @@ void openCustomerForm({
), ),
), ),
); );
} } */

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/customers/blocs/customers_cubit.dart'; import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
class QuickCustomerDialog extends StatefulWidget { class QuickCustomerDialog extends StatefulWidget {
final String initialQuery; final String initialQuery;
@@ -16,6 +16,7 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
final _phoneCtrl = TextEditingController(); final _phoneCtrl = TextEditingController();
final _emailCtrl = TextEditingController(); final _emailCtrl = TextEditingController();
final _noteCtrl = TextEditingController(); final _noteCtrl = TextEditingController();
bool _isBusiness = false; // Aggiungiamo un campo per il tipo di cliente
bool _isLoading = false; bool _isLoading = false;
@@ -43,13 +44,12 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
// Chiamata al Cubit (aggiorna i parametri in base a come li hai definiti) // Chiamata al Cubit (aggiorna i parametri in base a come li hai definiti)
final newCustomer = await context final newCustomer = await context
.read<CustomersCubit>() .read<CustomerFormCubit>()
.quickCreateCustomer( .quickCreateCustomer(
isBusiness: _isBusiness,
name: _nameCtrl.text.trim(), name: _nameCtrl.text.trim(),
phone: _phoneCtrl.text.trim(), phone: _phoneCtrl.text.trim(),
// Aggiungi questi se li hai inseriti nel tuo CustomerCubit: email: _emailCtrl.text.trim(),
// email: _emailCtrl.text.trim(),
// note: _noteCtrl.text.trim(),
); );
setState(() => _isLoading = false); setState(() => _isLoading = false);
@@ -67,6 +67,41 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
ChoiceChip(
label: const Text('Privato (Domestico)'),
selected: _isBusiness == false,
selectedColor: Colors.blue.withValues(alpha: 0.2),
checkmarkColor: Colors.blue.shade700,
onSelected: (selected) {
if (selected) {
setState(() {
_isBusiness = false;
});
}
},
),
const SizedBox(width: 12),
ChoiceChip(
label: const Text('Business (P.IVA)'),
selected: _isBusiness == true,
selectedColor: Colors.orange.withValues(alpha: 0.2),
checkmarkColor: Colors.orange.shade700,
onSelected: (selected) {
if (selected) {
setState(() {
_isBusiness = true;
});
}
},
),
],
),
),
const Divider(height: 32),
TextField( TextField(
controller: _nameCtrl, controller: _nameCtrl,
autofocus: true, // Focus immediato! autofocus: true, // Focus immediato!

View File

@@ -0,0 +1,71 @@
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/tasks/data/task_repository.dart';
import 'package:flux/features/tasks/models/task_model.dart';
import 'package:flux/features/tasks/models/task_status.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
part 'dashboard_task_list_state.dart';
class DashboardTaskListCubit extends Cubit<DashboardTaskListState> {
final TaskRepository _repository = GetIt.I.get<TaskRepository>();
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
RealtimeChannel? _taskChannel;
DashboardTaskListCubit() : super(DashboardTaskListState());
void startListening({required String staffId}) async {
emit(state.copyWith(status: DashboardTaskListStatus.loading));
await _loadTasks(staffId: staffId);
_taskChannel?.unsubscribe();
_taskChannel = _supabase
.channel('public:tasks_staff_$staffId')
.onPostgresChanges(
event: PostgresChangeEvent.all,
schema: 'public',
table: 'tasks',
filter: PostgresChangeFilter(
type: PostgresChangeFilterType.eq,
column: 'staff_id',
value: staffId,
),
callback: (payload) {
_loadTasks(staffId: staffId);
},
);
_taskChannel?.subscribe();
}
Future<void> _loadTasks({required String staffId}) async {
try {
final tasks = await _repository.getTasks(
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
staffId: staffId,
statuses: [TaskStatus.open, TaskStatus.inProgress],
);
emit(
state.copyWith(
status: DashboardTaskListStatus.success,
tasks: tasks,
errorMessage: null,
),
);
} catch (e) {
emit(
state.copyWith(
status: DashboardTaskListStatus.failure,
errorMessage: e.toString(),
),
);
}
}
@override
Future<void> close() {
_taskChannel?.unsubscribe();
return super.close();
}
}

View File

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

View File

@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
import 'package:go_router/go_router.dart';
import 'package:flux/features/tasks/models/task_status.dart';
class DashboardTasksCard extends StatelessWidget {
const DashboardTasksCard({super.key});
@override
Widget build(BuildContext context) {
// Recuperiamo lo staff (o l'utente) loggato
// Adatta il getter in base a come è strutturato il tuo SessionState
final currentStaffId = context
.read<SessionCubit>()
.state
.currentStaffMember
?.id;
if (currentStaffId == null) {
return const SizedBox.shrink(); // Sicurezza se lo stato non è pronto
}
return BlocProvider(
create: (context) =>
DashboardTaskListCubit()..startListening(staffId: currentStaffId),
child: const _DashboardTasksCardContent(),
);
}
}
class _DashboardTasksCardContent extends StatelessWidget {
const _DashboardTasksCardContent();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const color =
Colors.orange; // Colore arancione per distinguerla dai Ticket blu
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
),
child: InkWell(
onTap: () => context.pushNamed(
Routes.tasks,
), // Porta alla lista completa (TaskListScreen)
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER DELLA CARD ---
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.assignment_outlined, // Icona a tema ToDo
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
"I Miei Task",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
Expanded(
child: BlocBuilder<DashboardTaskListCubit, DashboardTaskListState>(
builder: (context, state) {
if (state.status == DashboardTaskListStatus.loading ||
state.status == DashboardTaskListStatus.initial) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == DashboardTaskListStatus.failure) {
return Center(
child: Text(
"Errore di caricamento ${state.errorMessage}",
style: TextStyle(color: theme.colorScheme.error),
),
);
}
if (state.tasks.isEmpty) {
return Center(
child: Text(
"Nessun task in sospeso. Ottimo lavoro!",
style: TextStyle(
color: context.secondaryText,
fontStyle: FontStyle.italic,
),
),
);
}
return ListView.separated(
itemCount: state.tasks.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: theme.dividerColor.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final task = state.tasks[index];
// Definisci il colore in base allo stato del task
final statusColor = task.status == TaskStatus.inProgress
? Colors.blue
: Colors.grey.shade400;
// Formattiamo la data (o indichiamo se non c'è)
final dueDateString = task.dueDate != null
? "${task.dueDate!.day}/${task.dueDate!.month}"
: "Nessuna";
// Controllo Ninja: Il task è già scaduto rispetto a oggi?
final isOverdue =
task.dueDate != null &&
task.dueDate!.isBefore(DateTime.now());
return InkWell(
onTap: () => context.pushNamed(
Routes.taskForm,
extra:
task, // Passiamo direttamente il modello intero se il tuo router lo accetta!
pathParameters: {'id': task.id ?? 'new'},
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 8,
height: 30,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 8),
Expanded(
flex: 7,
child: Text(
task.title,
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 3,
child: Align(
alignment: Alignment.centerRight,
child: Text(
dueDateString,
style: TextStyle(
color: isOverdue
? theme.colorScheme.error
: context.secondaryText,
fontSize: 12,
fontWeight: isOverdue
? FontWeight.bold
: FontWeight.normal,
),
),
),
),
],
),
),
);
},
);
},
),
),
],
),
),
),
);
}
}

View File

@@ -17,12 +17,12 @@ class LatestStoreOperationsBloc
status: LatestStoreOperationsStatus.initial, status: LatestStoreOperationsStatus.initial,
), ),
) { ) {
on<InitLastStoreOperationsEvent>((event, emit) async { on<InitLatestStoreOperationsEvent>((event, emit) async {
emit(state.copyWith(status: LatestStoreOperationsStatus.loading)); emit(state.copyWith(status: LatestStoreOperationsStatus.loading));
try { try {
// 1. Creiamo uno stream "intermedio" che idrata i dati // 1. Creiamo uno stream "intermedio" che idrata i dati
final hydratedStream = _repository final hydratedStream = _repository
.getLastStoreOperationsStream(storeId: event.storeId, limit: 5) .getLatestStoreOperationsStream(storeId: event.storeId, limit: 10)
.asyncMap((List<OperationModel> rawOperations) async { .asyncMap((List<OperationModel> rawOperations) async {
// Questo gira ad ogni "scatto" dello stream di Supabase // Questo gira ad ogni "scatto" dello stream di Supabase
List<OperationModel> fullyHydratedOperations = []; List<OperationModel> fullyHydratedOperations = [];

View File

@@ -7,10 +7,10 @@ sealed class LatestStoreOperationsEvent extends Equatable {
List<Object> get props => []; List<Object> get props => [];
} }
class InitLastStoreOperationsEvent extends LatestStoreOperationsEvent { class InitLatestStoreOperationsEvent extends LatestStoreOperationsEvent {
final String storeId; final String storeId;
const InitLastStoreOperationsEvent(this.storeId); const InitLatestStoreOperationsEvent(this.storeId);
@override @override
List<Object> get props => [storeId]; List<Object> get props => [storeId];

View File

@@ -18,7 +18,7 @@ class LatestStoreOperationsCard extends StatelessWidget {
// 1. Creiamo il Bloc e facciamo partire subito la query // 1. Creiamo il Bloc e facciamo partire subito la query
create: (context) => create: (context) =>
LatestStoreOperationsBloc() LatestStoreOperationsBloc()
..add(InitLastStoreOperationsEvent(currentStoreId ?? '')), ..add(InitLatestStoreOperationsEvent(currentStoreId ?? '')),
child: BlocListener<SessionCubit, SessionState>( child: BlocListener<SessionCubit, SessionState>(
// 2. MAGIA: Se l'utente cambia negozio dalla barra in alto, riavviamo lo stream! // 2. MAGIA: Se l'utente cambia negozio dalla barra in alto, riavviamo lo stream!
listenWhen: (previous, current) => listenWhen: (previous, current) =>
@@ -26,7 +26,7 @@ class LatestStoreOperationsCard extends StatelessWidget {
listener: (context, state) { listener: (context, state) {
if (state.currentStore?.id != null) { if (state.currentStore?.id != null) {
context.read<LatestStoreOperationsBloc>().add( context.read<LatestStoreOperationsBloc>().add(
InitLastStoreOperationsEvent(state.currentStore!.id!), InitLatestStoreOperationsEvent(state.currentStore!.id!),
); );
} }
}, },
@@ -45,13 +45,13 @@ class _LatestOperationsCardContent extends StatelessWidget {
return Card( return Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)), side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
), ),
child: InkWell( child: InkWell(
onTap: () => context.pushNamed(Routes.operations), onTap: () => context.pushNamed(Routes.operations),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(12.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -137,8 +137,8 @@ class _LatestOperationsCardContent extends StatelessWidget {
return InkWell( return InkWell(
onTap: () => context.pushNamed( onTap: () => context.pushNamed(
Routes.operationForm, Routes.operationForm,
extra: (createdBy: null, operation: operation),
pathParameters: {'id': operation.id!}, pathParameters: {'id': operation.id!},
extra: operation,
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -162,7 +162,7 @@ class _LatestOperationsCardContent extends StatelessWidget {
Expanded( Expanded(
flex: 5, flex: 5,
child: Text( child: Text(
operation.reference, operation.type,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: context.primaryText, color: context.primaryText,

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,16 @@ import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart'; import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/staff_selector_modal.dart'; import 'package:flux/core/widgets/staff_selector_modal.dart';
import 'package:flux/features/home/dashboard_task_list/ui/dashboard_tasks_card.dart';
import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart'; import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart';
import 'package:flux/features/home/latest_store_tickets/ui/latest_store_tickets_card.dart';
import 'package:flux/features/home/ui/quick_actions_widget.dart'; import 'package:flux/features/home/ui/quick_actions_widget.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/notes/data/notes_repository.dart';
import 'package:flux/features/notes/models/note_model.dart';
import 'package:flux/features/notes/ui/dashboard_notes_widget.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
@@ -61,34 +67,16 @@ class HomeScreen extends StatelessWidget {
childAspectRatio: 1.3, childAspectRatio: 1.3,
), ),
delegate: SliverChildListDelegate([ delegate: SliverChildListDelegate([
LatestStoreOperationsCard(),
LatestStoreTicketsCard(),
_buildDashboardWidget( _buildDashboardWidget(
title: context.l10n.homeExpiringContracts, title: context.l10n.homeExpiringContracts,
icon: Icons.assignment_late_outlined, icon: Icons.assignment_late_outlined,
color: Colors.orange, color: Colors.orange,
context: context, context: context,
), ),
_buildDashboardWidget( DashboardNotesWidget(),
title: context.l10n.commonStickyNotes, DashboardTasksCard(),
icon: Icons.sticky_note_2_outlined,
color: Colors.yellow.shade700,
context: context,
),
_buildDashboardWidget(
title: context.l10n.homeMyTasks,
icon: Icons.check_box_outlined,
color: Colors.green,
context: context,
),
LatestStoreOperationsCard(),
_buildDashboardWidget(
title: context.l10n.homeLatestOperationTickets,
icon: Icons.support_agent_outlined,
color: Colors.purple,
context: context,
onTap: () => context.pushNamed(
Routes.tickets,
), // <-- Aggiunto!
),
]), ]),
), ),
), ),
@@ -218,8 +206,26 @@ class HomeScreen extends StatelessWidget {
icon: Icons.note_add, icon: Icons.note_add,
label: context.l10n.commonNote, label: context.l10n.commonNote,
color: Colors.amber, color: Colors.amber,
onTap: () { onTap: () async {
// TODO: Quando faremo il modale/pagina delle note final companyId = context.read<SessionCubit>().state.company!.id!;
final currentStaffId = context
.read<SessionCubit>()
.state
.currentStaffMember!
.id!;
final emptyNote = NoteModel.empty(
createdBy: currentStaffId,
companyId: companyId,
);
final noteId = await GetIt.I.get<NotesRepository>().saveNote(
emptyNote,
);
if (!context.mounted) return;
context.pushNamed(
Routes.noteForm,
pathParameters: {'id': noteId},
extra: emptyNote.copyWith(id: noteId),
);
}, },
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -228,7 +234,7 @@ class HomeScreen extends StatelessWidget {
label: context.l10n.commonTask, label: context.l10n.commonTask,
color: Colors.teal, color: Colors.teal,
onTap: () { onTap: () {
// TODO: Quando faremo i task context.pushNamed(Routes.taskForm, pathParameters: {'id': 'new'});
}, },
), ),
], ],

View File

@@ -1,4 +1,5 @@
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/brand_model.dart'; import '../models/brand_model.dart';
@@ -14,7 +15,7 @@ class ProductRepository {
Future<List<BrandModel>> getBrands() async { Future<List<BrandModel>> getBrands() async {
try { try {
final response = await _supabase final response = await _supabase
.from('brand') .from(Tables.brands)
.select() .select()
.eq('company_id', _companyId) .eq('company_id', _companyId)
.eq('is_active', true) .eq('is_active', true)
@@ -30,7 +31,7 @@ class ProductRepository {
Future<BrandModel> upsertBrand(BrandModel brand) async { Future<BrandModel> upsertBrand(BrandModel brand) async {
try { try {
final response = await _supabase final response = await _supabase
.from('brand') .from(Tables.brands)
.upsert(brand.toJson()) .upsert(brand.toJson())
.select() .select()
.single(); .single();
@@ -47,7 +48,7 @@ class ProductRepository {
Future<List<ModelModel>> getModelsByBrand(String brandId) async { Future<List<ModelModel>> getModelsByBrand(String brandId) async {
try { try {
final response = await _supabase final response = await _supabase
.from('model') .from(Tables.models)
.select() .select()
.eq('brand_id', brandId) .eq('brand_id', brandId)
.eq('is_active', true) .eq('is_active', true)
@@ -62,7 +63,7 @@ class ProductRepository {
Future<List<ModelModel>> getModels() async { Future<List<ModelModel>> getModels() async {
try { try {
final response = await _supabase final response = await _supabase
.from('model') .from(Tables.models)
.select() .select()
.eq('is_active', true) .eq('is_active', true)
.order('name'); .order('name');
@@ -77,7 +78,7 @@ class ProductRepository {
Future<ModelModel> upsertModel(ModelModel model) async { Future<ModelModel> upsertModel(ModelModel model) async {
try { try {
final response = await _supabase final response = await _supabase
.from('model') .from(Tables.models)
.upsert(model.toJson()) .upsert(model.toJson())
.select() .select()
.single(); .single();
@@ -102,7 +103,7 @@ class ProductRepository {
Future<List<ModelModel>> searchModels(String query) async { Future<List<ModelModel>> searchModels(String query) async {
try { try {
final response = await _supabase final response = await _supabase
.from('model') .from(Tables.models)
.select() .select()
.ilike('name_with_brand', '%$query%') // Cerca ovunque nel nome .ilike('name_with_brand', '%$query%') // Cerca ovunque nel nome
.eq('is_active', true) .eq('is_active', true)

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/theme/theme.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/product_dialogs.dart'; import 'package:flux/features/master_data/products/ui/product_dialogs.dart';
@@ -63,9 +64,12 @@ class ModelsList extends StatelessWidget {
: Icons.visibility_off_outlined, : Icons.visibility_off_outlined,
color: model.isActive ? context.accent : Colors.grey, color: model.isActive ? context.accent : Colors.grey,
), ),
onPressed: () => context onPressed: () =>
.read<ProductsCubit>() context.read<ProductsCubit>().toggleStatus(
.toggleStatus('model', model.id!, model.isActive), Tables.models,
model.id!,
model.isActive,
),
), ),
], ],
), ),

View File

@@ -1,176 +0,0 @@
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/master_data/providers/data/provider_repository.dart';
import 'package:flux/features/master_data/store/models/store_model.dart';
import 'package:get_it/get_it.dart';
import '../models/provider_model.dart';
class ProvidersState extends Equatable {
final List<ProviderModel> allProviders;
final List<String> associatedIds;
// NUOVO CAMPO: Lista dei provider pronti per essere usati nel form pratiche
final List<ProviderModel> activeProviders;
final bool isLoading;
final String? errorMessage;
const ProvidersState({
this.allProviders = const [],
this.associatedIds = const [],
this.activeProviders = const [], // Inizializza
this.isLoading = false,
this.errorMessage,
});
ProvidersState copyWith({
List<ProviderModel>? allProviders,
List<String>? associatedIds,
List<ProviderModel>? activeProviders, // Aggiungi qui
bool? isLoading,
String? errorMessage,
}) {
return ProvidersState(
allProviders: allProviders ?? this.allProviders,
associatedIds: associatedIds ?? this.associatedIds,
activeProviders: activeProviders ?? this.activeProviders, // Aggiungi qui
isLoading: isLoading ?? this.isLoading,
errorMessage:
errorMessage ??
this.errorMessage, // Correzione bug: mancava "?? this.errorMessage" nel tuo originale
);
}
@override
List<Object?> get props => [
allProviders,
associatedIds,
activeProviders, // Aggiungi qui
isLoading,
errorMessage,
];
}
class ProvidersCubit extends Cubit<ProvidersState> {
final ProviderRepository _repository = GetIt.I<ProviderRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
ProvidersCubit() : super(const ProvidersState());
// Carica i provider della company e quelli associati a uno store specifico
Future<void> loadProviders({StoreModel? store}) async {
emit(state.copyWith(isLoading: true));
try {
final all = await _repository.fetchAllCompanyProviders(
_sessionCubit.state.company!.id!,
);
List<String> associated = [];
if (store != null) {
associated = await _repository.fetchAssociatedProviderIds(store.id!);
}
emit(
state.copyWith(
allProviders: all,
associatedIds: associated,
isLoading: false,
),
);
} catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
}
}
Future<void> loadActiveProvidersForStore(String storeId) async {
emit(state.copyWith(isLoading: true));
try {
final activeList = await _repository.fetchActiveProvidersForStore(
storeId,
);
emit(state.copyWith(activeProviders: activeList, isLoading: false));
} catch (e) {
emit(
state.copyWith(
isLoading: false,
errorMessage: "Errore caricamento gestori: $e",
),
);
}
}
// Aggiunge o rimuove l'associazione con lo store
Future<void> toggleProviderAssociation({
required String providerId,
required String storeId,
required bool isCurrentlyAssociated,
}) async {
try {
if (isCurrentlyAssociated) {
await _repository.disassociateProviderFromStore(
providerId: providerId,
storeId: storeId,
);
// Aggiorniamo lo stato locale rimuovendo l'ID
final newIds = List<String>.from(state.associatedIds)
..remove(providerId);
emit(state.copyWith(associatedIds: newIds));
} else {
await _repository.associateProviderToStore(
providerId: providerId,
storeId: storeId,
);
// Aggiorniamo lo stato locale aggiungendo l'ID
final newIds = List<String>.from(state.associatedIds)..add(providerId);
emit(state.copyWith(associatedIds: newIds));
}
} catch (e) {
emit(state.copyWith(errorMessage: "Errore durante l'aggiornamento: $e"));
}
}
// Salvataggio/Update anagrafica (nuovo o modifica)
Future<void> saveProvider(
ProviderModel provider,
List<String> selectedStoreIds,
) async {
emit(state.copyWith(isLoading: true));
// Assicuriamoci di settare la companyId prima di salvare
provider = provider.copyWith(companyId: _sessionCubit.state.company!.id);
try {
// 1. Salviamo l'anagrafica (upsert)
// Se è un nuovo provider, l'ID potrebbe essere generato qui dal DB
// Quindi carichiamo il risultato del salvataggio per avere l'ID
final response = await _repository.saveProvider(provider);
// Assumiamo che il saveProvider restituisca l'oggetto salvato con l'ID
final pId = provider.id ?? response.id;
// 2. Sincronizziamo i negozi
await _repository.syncProviderStores(pId!, selectedStoreIds);
// 3. Ricarichiamo tutto
await loadProviders();
} catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
}
}
Future<void> saveProviderWithStores(
ProviderModel provider,
List<String> storeIds,
) async {
emit(state.copyWith(isLoading: true));
try {
// 1. Salva l'anagrafica provider
await _repository.saveProvider(provider);
// 2. Sincronizza i negozi (la via più semplice è cancellare e reinserire
// o fare un confronto tra i presenti e i nuovi)
await _repository.syncProviderStores(provider.id!, storeIds);
await loadProviders();
} catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
}
}
}

View File

@@ -0,0 +1,190 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; // Per estrarre gli store
import '../models/provider_model.dart';
import '../models/provider_role.dart';
import '../models/provider_location_model.dart';
import '../data/provider_repository.dart';
part 'provider_form_state.dart';
class ProviderFormCubit extends Cubit<ProviderFormState> {
final ProviderRepository _repository = GetIt.I.get<ProviderRepository>();
final _client = Supabase.instance.client; // Lo usiamo al volo per gli store
ProviderFormCubit()
: super(
ProviderFormState(
provider: ProviderModel.empty(
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
),
),
);
// --- INIZIALIZZAZIONE ---
Future<void> initForm({
required String companyId,
ProviderModel? existingProvider,
}) async {
emit(state.copyWith(status: ProviderFormStatus.loading));
try {
// 1. Scarichiamo tutti i negozi dell'azienda
final storesResponse = await _client
.from('store')
.select('id, name')
.eq('company_id', companyId);
// 2. Se stiamo modificando, carichiamo gli store collegati
List<String> linkedStoreIds = [];
if (existingProvider != null && existingProvider.id != null) {
// ... (Vecchio codice di recupero)
final links = await _client
.from('providers_in_stores')
.select('store_id')
.eq('provider_id', existingProvider.id!);
linkedStoreIds = (links as List)
.map((l) => l['store_id'] as String)
.toList();
} else {
// --- IL TOCCO NINJA: AUTO-SELEZIONE ---
// Se stiamo creando un nuovo fornitore e c'è 1 solo negozio in tutto il DB, accendilo!
if ((storesResponse as List).length == 1) {
linkedStoreIds.add(storesResponse.first['id'] as String);
}
}
emit(
state.copyWith(
status: ProviderFormStatus.initial,
provider:
existingProvider ?? ProviderModel.empty(companyId: companyId),
availableStores: storesResponse as List<dynamic>,
selectedStoreIds: linkedStoreIds,
localLocations: existingProvider?.locations ?? [],
),
);
} catch (e) {
emit(
state.copyWith(
status: ProviderFormStatus.failure,
errorMessage: 'Errore durante l\'inizializzazione: $e',
),
);
}
}
// --- AGGIORNAMENTO CAMPI ---
void updateFields({
String? name,
String? businessName,
String? vatNumber,
String? fiscalCode,
String? sdiCode,
String? emailPec,
}) {
emit(
state.copyWith(
provider: state.provider.copyWith(
name: name,
businessName: businessName,
vatNumber: vatNumber,
fiscalCode: fiscalCode,
sdiCode: sdiCode,
emailPec: emailPec,
),
),
);
}
// --- GESTIONE RUOLI (CHIPS) ---
void toggleRole(ProviderRole role) {
final currentRoles = List<ProviderRole>.from(state.provider.roles);
if (currentRoles.contains(role)) {
currentRoles.remove(role);
} else {
currentRoles.add(role);
}
emit(
state.copyWith(provider: state.provider.copyWith(roles: currentRoles)),
);
}
// --- GESTIONE NEGOZI ABILITATI (CHECKBOX) ---
void toggleStore(String storeId) {
final currentStoreIds = List<String>.from(state.selectedStoreIds);
if (currentStoreIds.contains(storeId)) {
currentStoreIds.remove(storeId);
} else {
currentStoreIds.add(storeId);
}
emit(state.copyWith(selectedStoreIds: currentStoreIds));
}
Future<void> addLocationLocal(ProviderLocationModel location) async {
final currentLocations = List<ProviderLocationModel>.from(
state.localLocations,
);
currentLocations.add(location);
emit(state.copyWith(localLocations: currentLocations));
}
void removeLocationLocal(int index) {
final currentLocations = List<ProviderLocationModel>.from(
state.localLocations,
);
if (index >= 0 && index < currentLocations.length) {
currentLocations.removeAt(index);
emit(state.copyWith(localLocations: currentLocations));
}
}
// --- SALVATAGGIO FINALE ---
Future<void> save() async {
// Sicurezza di base
if (state.provider.name.trim().isEmpty) {
emit(
state.copyWith(
status: ProviderFormStatus.failure,
errorMessage: 'Il nome è obbligatorio',
),
);
return;
}
emit(state.copyWith(status: ProviderFormStatus.loading));
try {
// Passiamo provider e storeId al repository che farà la magia
final savedProvider = await _repository.saveProvider(
state.provider,
state.selectedStoreIds,
);
if (state.localLocations.isNotEmpty) {
for (var loc in state.localLocations) {
final locToSave = loc.copyWith(
providerId: savedProvider.id!,
companyId: savedProvider.companyId,
);
await _repository.saveLocation(locToSave);
}
}
emit(
state.copyWith(
status: ProviderFormStatus.success,
provider: savedProvider,
),
);
} catch (e) {
emit(
state.copyWith(
status: ProviderFormStatus.failure,
errorMessage: 'Errore di salvataggio: $e',
),
);
}
}
}

View File

@@ -0,0 +1,55 @@
part of 'provider_form_cubit.dart';
// Importa il tuo StoreModel se lo hai
enum ProviderFormStatus { initial, loading, success, failure }
class ProviderFormState extends Equatable {
final ProviderFormStatus status;
final ProviderModel provider;
// Dati di supporto per l'interfaccia
final List<dynamic>
availableStores; // Metti List<StoreModel> se hai il modello
final List<String> selectedStoreIds; // IDs dei negozi in cui è attivo
final List<ProviderLocationModel>
localLocations; // Sedi aggiunte prima del salvataggio
final String? errorMessage;
const ProviderFormState({
this.status = ProviderFormStatus.initial,
required this.provider,
this.availableStores = const [],
this.selectedStoreIds = const [],
this.localLocations = const [],
this.errorMessage,
});
ProviderFormState copyWith({
ProviderFormStatus? status,
ProviderModel? provider,
List<dynamic>? availableStores,
List<String>? selectedStoreIds,
List<ProviderLocationModel>? localLocations,
String? errorMessage,
}) {
return ProviderFormState(
status: status ?? this.status,
provider: provider ?? this.provider,
availableStores: availableStores ?? this.availableStores,
selectedStoreIds: selectedStoreIds ?? this.selectedStoreIds,
localLocations: localLocations ?? this.localLocations,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
status,
provider,
availableStores,
selectedStoreIds,
localLocations,
errorMessage,
];
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:get_it/get_it.dart';
import '../models/provider_model.dart';
import '../data/provider_repository.dart';
part 'provider_list_state.dart';
class ProviderListCubit extends Cubit<ProviderListState> {
final ProviderRepository _repository = GetIt.I.get<ProviderRepository>();
ProviderListCubit() : super(const ProviderListState());
Future<void> loadProviders(String storeId) async {
emit(state.copyWith(status: ProviderListStatus.loading));
try {
final providers = await _repository.getProvidersByStore(storeId);
emit(
state.copyWith(
status: ProviderListStatus.success,
providers: providers,
),
);
} catch (e) {
emit(
state.copyWith(
status: ProviderListStatus.failure,
errorMessage: e.toString(),
),
);
}
}
Future<void> loadAllProviders() async {
emit(state.copyWith(status: ProviderListStatus.loading));
try {
final allProviders = await _repository.getAllCompanyProviders();
emit(
state.copyWith(
status: ProviderListStatus.success,
allProviders: allProviders,
),
);
} catch (e) {
emit(
state.copyWith(
status: ProviderListStatus.failure,
errorMessage: e.toString(),
),
);
}
}
}

View File

@@ -0,0 +1,34 @@
part of 'provider_list_cubit.dart';
enum ProviderListStatus { initial, loading, success, failure }
class ProviderListState extends Equatable {
final ProviderListStatus status;
final List<ProviderModel> providers;
final List<ProviderModel> allProviders;
final String? errorMessage;
const ProviderListState({
this.status = ProviderListStatus.initial,
this.providers = const [],
this.allProviders = const [],
this.errorMessage,
});
ProviderListState copyWith({
ProviderListStatus? status,
List<ProviderModel>? providers,
List<ProviderModel>? allProviders,
String? errorMessage,
}) {
return ProviderListState(
status: status ?? this.status,
providers: providers ?? this.providers,
allProviders: allProviders ?? this.allProviders,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [status, providers, allProviders, errorMessage];
}

View File

@@ -1,137 +1,88 @@
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/provider_model.dart'; import '../models/provider_model.dart';
import '../models/provider_location_model.dart';
class ProviderRepository { class ProviderRepository {
final _supabase = Supabase.instance.client; final _supabase = GetIt.I.get<SupabaseClient>();
final _companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
// --- ASSOCIAZIONE PROVIDER <-> STORE --- // 1. Carica i provider abilitati per uno specifico Store
Future<List<ProviderModel>> getProvidersByStore(String storeId) async {
// Aggiunge un provider a un negozio (Attiva mandato) final response = await _supabase
Future<void> associateProviderToStore({ .from(Tables.providersInStores)
required String providerId, .select('''
required String storeId, provider_id,
}) async { provider:${Tables.providers} (
try { *,
await _supabase.from('providers_in_stores').insert({ ${Tables.providerLocations} (*)
'provider_id': providerId,
'store_id': storeId,
});
} catch (e) {
throw Exception('Errore durante l\'associazione provider: $e');
}
}
// Rimuove un provider da un negozio (Disattiva mandato)
Future<void> disassociateProviderFromStore({
required String providerId,
required String storeId,
}) async {
try {
await _supabase
.from('providers_in_stores')
.delete()
.eq('provider_id', providerId)
.eq('store_id', storeId);
} catch (e) {
throw Exception('Errore durante la disassociazione provider: $e');
}
}
// Recupera tutti i provider di una company (per la lista generale)
Future<List<ProviderModel>> fetchAllCompanyProviders(String companyId) async {
try {
final response = await _supabase
.from('provider')
.select('''
*,
associated_stores:providers_in_stores (
store (
*
)
) )
''') ''')
.eq('company_id', companyId) .eq('store_id', storeId)
.order('name'); .order('name', referencedTable: 'provider');
return (response as List).map((m) => ProviderModel.fromMap(m)).toList(); // Mappiamo i risultati estraendo l'oggetto 'provider' annidato
} catch (e) { return (response as List).map((row) {
throw 'Errore fetch providers: $e'; return ProviderModel.fromMap(row['provider'] as Map<String, dynamic>);
} }).toList();
} }
// Recupera gli ID dei provider associati a uno store (utile per le checkbox) // 2. Carica TUTTI i provider della Company (per la gestione anagrafica)
Future<List<String>> fetchAssociatedProviderIds(String storeId) async { Future<List<ProviderModel>> getAllCompanyProviders() async {
try { final response = await _supabase
final response = await _supabase .from(Tables.providers)
.from('providers_in_stores') .select('*, ${Tables.providerLocations} (*)')
.select('provider_id') .order('name');
.eq('store_id', storeId);
return (response as List) return (response as List)
.map((item) => item['provider_id'].toString()) .map((row) => ProviderModel.fromMap(row as Map<String, dynamic>))
.toList();
}
// 3. Salvataggio atomico (Upsert) del Provider
Future<ProviderModel> saveProvider(
ProviderModel provider,
List<String> enabledStoreIds,
) async {
// A. Salva/Aggiorna il Provider principale
final providerWithCompany = provider.copyWith(companyId: _companyId);
final savedRow = await _supabase
.from(Tables.providers)
.upsert(providerWithCompany.toMap())
.select()
.single();
final savedProvider = ProviderModel.fromMap(savedRow);
// B. Sincronizza gli Store (Cancelliamo i vecchi e mettiamo i nuovi per semplicità)
// In un'app ad alto traffico faremmo un confronto, qui l'upsert totale è più veloce da scrivere.
await _supabase
.from(Tables.providersInStores)
.delete()
.eq('provider_id', savedProvider.id!);
if (enabledStoreIds.isNotEmpty) {
final storeLinks = enabledStoreIds
.map((sId) => {'provider_id': savedProvider.id, 'store_id': sId})
.toList(); .toList();
} catch (e) {
throw Exception('Errore recupero ID associati: $e'); await _supabase.from(Tables.providersInStores).insert(storeLinks);
} }
return savedProvider;
} }
// --- FUNZIONI STANDARD --- // 4. Gestione Sedi (Locations)
Future<void> saveLocation(ProviderLocationModel location) async {
// Questa la userai nel Form Servizi: carica solo i provider abilitati per lo store await _supabase.from(Tables.providerLocations).upsert(location.toMap());
Future<List<ProviderModel>> fetchActiveProvidersForStore(
String storeId,
) async {
try {
final response = await _supabase
.from('provider')
.select('*, providers_in_stores!inner(store_id)')
.eq('providers_in_stores.store_id', storeId)
.eq('is_active', true);
return (response as List).map((m) => ProviderModel.fromMap(m)).toList();
} catch (e) {
throw Exception('Errore fetch provider attivi: $e');
}
} }
// Salva o aggiorna l'anagrafica del Provider Future<void> deleteLocation(String locationId) async {
Future<ProviderModel> saveProvider(ProviderModel provider) async { await _supabase
try { .from(Tables.providerLocations)
// .select().single() è fondamentale per farsi restituire .delete()
// l'oggetto appena creato/aggiornato con l'ID .eq('id', locationId);
final response = await _supabase
.from('provider')
.upsert(provider.toMap())
.select()
.single();
return ProviderModel.fromMap(response); // <--- DEVE ESSERCI IL RETURN
} catch (e) {
rethrow; // <--- Rilancia l'errore al Cubit, non ritornare null!
}
}
Future<void> syncProviderStores(
String providerId,
List<String> storeIds,
) async {
try {
// 1. Eliminiamo tutte le associazioni correnti per questo provider
await _supabase
.from('providers_in_stores')
.delete()
.eq('provider_id', providerId);
// 2. Se ci sono nuovi store da associare, li inseriamo
if (storeIds.isNotEmpty) {
final inserts = storeIds
.map((sId) => {'provider_id': providerId, 'store_id': sId})
.toList();
await _supabase.from('providers_in_stores').insert(inserts);
}
} catch (e) {
throw Exception('Errore durante la sincronizzazione store: $e');
}
} }
} }

View File

@@ -0,0 +1,109 @@
import 'package:equatable/equatable.dart';
class ProviderLocationModel extends Equatable {
final String? id;
final String providerId;
final String companyId;
final String name; // Es: "Laboratorio Centrale"
final String address;
final String city;
final String zipCode;
final String province;
final String? contactPerson;
final bool isMain;
const ProviderLocationModel({
this.id,
required this.providerId,
required this.companyId,
required this.name,
required this.address,
required this.city,
required this.zipCode,
required this.province,
this.contactPerson,
this.isMain = false,
});
factory ProviderLocationModel.empty() {
return const ProviderLocationModel(
providerId: '',
companyId: '',
name: '',
address: '',
city: '',
zipCode: '',
province: '',
);
}
ProviderLocationModel copyWith({
String? id,
String? providerId,
String? companyId,
String? name,
String? address,
String? city,
String? zipCode,
String? province,
String? contactPerson,
bool? isMain,
}) {
return ProviderLocationModel(
id: id ?? this.id,
providerId: providerId ?? this.providerId,
companyId: companyId ?? this.companyId,
name: name ?? this.name,
address: address ?? this.address,
city: city ?? this.city,
zipCode: zipCode ?? this.zipCode,
province: province ?? this.province,
contactPerson: contactPerson ?? this.contactPerson,
isMain: isMain ?? this.isMain,
);
}
factory ProviderLocationModel.fromMap(Map<String, dynamic> map) {
return ProviderLocationModel(
id: map['id'] as String,
providerId: map['provider_id'] as String,
companyId: map['company_id'] as String,
name: map['name'] as String,
address: map['address'] as String,
city: map['city'] as String,
zipCode: map['zip_code'] as String,
province: map['province'] as String,
contactPerson: map['contact_person'] as String?,
isMain: map['is_main'] as bool? ?? false,
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'provider_id': providerId,
'company_id': companyId,
'name': name,
'address': address,
'city': city,
'zip_code': zipCode,
'province': province,
'contact_person': contactPerson,
'is_main': isMain,
};
}
@override
List<Object?> get props => [
id,
providerId,
companyId,
name,
address,
city,
zipCode,
province,
contactPerson,
isMain,
];
}

View File

@@ -1,134 +1,170 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/features/master_data/store/models/store_model.dart';
import 'provider_location_model.dart';
import 'provider_role.dart';
class ProviderModel extends Equatable { class ProviderModel extends Equatable {
final String? id; final String? id;
final String name;
final bool landline;
final bool mobile;
final bool energy;
final bool insurance;
final bool entertainment;
final bool financing;
final bool telepass;
final bool other;
final bool isActive;
final String companyId; final String companyId;
final List<StoreModel> associatedStores; final String name; // Nome "commerciale" per riconoscerlo velocemente
final bool isActive;
// Dati fiscali e legali
final String? businessName; // Ragione Sociale
final String? vatNumber; // P.IVA
final String? fiscalCode; // C.F.
final String? sdiCode; // Codice Univoco (SDI)
final String? emailPec;
final String? legalAddress;
final String? legalCity;
final String? legalZip;
final String? legalProvince;
// Ruoli e Sedi (Relazioni)
final List<ProviderRole> roles;
final List<ProviderLocationModel>? locations;
const ProviderModel({ const ProviderModel({
this.id, this.id,
required this.name,
required this.landline,
required this.mobile,
required this.energy,
required this.insurance,
required this.entertainment,
required this.financing,
required this.telepass,
required this.other,
required this.isActive,
required this.companyId, required this.companyId,
this.associatedStores = const [], required this.name,
this.isActive = true,
this.businessName,
this.vatNumber,
this.fiscalCode,
this.sdiCode,
this.emailPec,
this.legalAddress,
this.legalCity,
this.legalZip,
this.legalProvince,
this.roles = const [],
this.locations,
}); });
factory ProviderModel.empty({required String companyId}) {
return ProviderModel(
companyId: companyId,
name: '',
isActive: true,
roles: const [],
);
}
ProviderModel copyWith({
String? id,
String? companyId,
String? name,
bool? isActive,
String? businessName,
String? vatNumber,
String? fiscalCode,
String? sdiCode,
String? emailPec,
String? legalAddress,
String? legalCity,
String? legalZip,
String? legalProvince,
List<ProviderRole>? roles,
List<ProviderLocationModel>? locations,
}) {
return ProviderModel(
id: id ?? this.id,
companyId: companyId ?? this.companyId,
name: name ?? this.name,
isActive: isActive ?? this.isActive,
businessName: businessName ?? this.businessName,
vatNumber: vatNumber ?? this.vatNumber,
fiscalCode: fiscalCode ?? this.fiscalCode,
sdiCode: sdiCode ?? this.sdiCode,
emailPec: emailPec ?? this.emailPec,
legalAddress: legalAddress ?? this.legalAddress,
legalCity: legalCity ?? this.legalCity,
legalZip: legalZip ?? this.legalZip,
legalProvince: legalProvince ?? this.legalProvince,
roles: roles ?? this.roles,
locations: locations ?? this.locations,
);
}
factory ProviderModel.fromMap(Map<String, dynamic> map) { factory ProviderModel.fromMap(Map<String, dynamic> map) {
// Estraiamo la lista dalla pivot e poi prendiamo l'oggetto 'store' annidato // Parsing sicuro dell'array testuale di Supabase per trasformarlo in Enum
final pivotList = map['associated_stores'] as List?; List<ProviderRole> parsedRoles = [];
List<StoreModel> stores = []; if (map['roles'] != null) {
if (pivotList != null) { final List<dynamic> rawRoles = map['roles'];
stores = pivotList for (var r in rawRoles) {
.where((item) => item['store'] != null) // Sicurezza final role = ProviderRole.fromString(r as String);
if (role != null) parsedRoles.add(role);
}
}
// Parsing della JOIN per le locations, se presenti nella query
List<ProviderLocationModel>? parsedLocations;
if (map['provider_locations'] != null) {
parsedLocations = (map['provider_locations'] as List<dynamic>)
.map( .map(
(item) => StoreModel.fromMap(item['store'] as Map<String, dynamic>), (item) =>
ProviderLocationModel.fromMap(item as Map<String, dynamic>),
) )
.toList(); .toList();
} }
return ProviderModel( return ProviderModel(
id: map['id'], id: map['id'] as String,
name: map['name'], companyId: map['company_id'] as String,
landline: map['landline'] ?? false, name: map['name'] as String,
mobile: map['mobile'] ?? false, isActive: map['is_active'] as bool? ?? true,
energy: map['energy'] ?? false, businessName: map['business_name'] as String?,
insurance: map['insurance'] ?? false, vatNumber: map['vat_number'] as String?,
entertainment: map['entertainment'] ?? false, fiscalCode: map['fiscal_code'] as String?,
financing: map['financing'] ?? false, sdiCode: map['sdi_code'] as String?,
telepass: map['telepass'] ?? false, emailPec: map['email_pec'] as String?,
other: map['other'] ?? false, legalAddress: map['legal_address'] as String?,
isActive: map['is_active'] ?? true, legalCity: map['legal_city'] as String?,
companyId: map['company_id'], legalZip: map['legal_zip'] as String?,
associatedStores: stores, legalProvince: map['legal_province'] as String?,
roles: parsedRoles,
locations: parsedLocations,
); );
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
final map = { Map<String, dynamic> baseMap = {
'name': name, if (id != null && id!.trim().isNotEmpty) 'id': id,
'landline': landline,
'mobile': mobile,
'energy': energy,
'insurance': insurance,
'entertainment': entertainment,
'financing': financing,
'telepass': telepass,
'other': other,
'is_active': isActive,
'company_id': companyId, 'company_id': companyId,
'name': name,
'is_active': isActive,
'business_name': businessName,
'vat_number': vatNumber,
'fiscal_code': fiscalCode,
'sdi_code': sdiCode,
'email_pec': emailPec,
'legal_address': legalAddress,
'legal_city': legalCity,
'legal_zip': legalZip,
'legal_province': legalProvince,
// Trasformiamo gli Enum di nuovo in stringhe per Supabase
'roles': roles.map((e) => e.name).toList(),
}; };
// AGGIUNGIAMO L'ID SOLO SE NON È NULLO return baseMap;
// Senza questo, l'upsert non sa dove andare a parare
if (id != null) {
map['id'] = id!;
}
return map;
} }
@override @override
List<Object?> get props => [ List<Object?> get props => [
id, id,
name,
landline,
mobile,
energy,
insurance,
entertainment,
financing,
telepass,
other,
isActive,
companyId, companyId,
associatedStores, name,
isActive,
businessName,
vatNumber,
fiscalCode,
sdiCode,
emailPec,
legalAddress,
legalCity,
legalZip,
legalProvince,
roles,
locations,
]; ];
ProviderModel copyWith({
String? id,
String? name,
bool? landline,
bool? mobile,
bool? energy,
bool? insurance,
bool? entertainment,
bool? financing,
bool? telepass,
bool? other,
bool? isActive,
String? companyId,
List<StoreModel>? associatedStores,
}) {
return ProviderModel(
id: id ?? this.id,
name: name ?? this.name,
landline: landline ?? this.landline,
mobile: mobile ?? this.mobile,
energy: energy ?? this.energy,
insurance: insurance ?? this.insurance,
entertainment: entertainment ?? this.entertainment,
financing: financing ?? this.financing,
telepass: telepass ?? this.telepass,
other: other ?? this.other,
isActive: isActive ?? this.isActive,
companyId: companyId ?? this.companyId,
associatedStores: associatedStores ?? this.associatedStores,
);
}
} }

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
enum ProviderRole {
landline('Fisso', Colors.blue),
mobile('Mobile', Colors.green),
energy('Energia', Colors.orange),
insurance('Assicurazioni', Colors.purple),
financing('Finanziamenti', Colors.teal),
entertainment('Intrattenimento', Colors.red),
telepass('Telepass', Colors.amber),
repairCenter('Centro Riparazioni', Colors.cyan),
partsSupplier('Fornitore Ricambi', Colors.indigo),
merchandiseSupplier('Fornitore Merce', Colors.brown);
final String displayValue;
final Color color; // <-- Il nostro tocco magico
const ProviderRole(this.displayValue, this.color);
static ProviderRole? fromString(String? value) {
if (value == null) return null;
try {
return ProviderRole.values.firstWhere((e) => e.name == value);
} catch (_) {
return null;
}
}
}

View File

@@ -0,0 +1,395 @@
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/providers/blocs/provider_form_cubit.dart';
import 'package:flux/features/master_data/providers/models/provider_location_model.dart';
import 'package:flux/features/master_data/providers/models/provider_model.dart';
import 'package:flux/features/master_data/providers/models/provider_role.dart';
import 'package:flux/features/master_data/providers/ui/provider_location_dialog.dart';
class ProviderFormScreen extends StatefulWidget {
final ProviderModel? existingProvider;
const ProviderFormScreen({super.key, this.existingProvider});
@override
State<ProviderFormScreen> createState() => _ProviderFormScreenState();
}
class _ProviderFormScreenState extends State<ProviderFormScreen> {
final _formKey = GlobalKey<FormState>();
// Controllers per i campi di testo
late final TextEditingController _nameCtrl;
late final TextEditingController _businessNameCtrl;
late final TextEditingController _vatCtrl;
late final TextEditingController _cfCtrl;
late final TextEditingController _sdiCtrl;
late final TextEditingController _pecCtrl;
@override
void initState() {
super.initState();
final p = widget.existingProvider;
_nameCtrl = TextEditingController(text: p?.name);
_businessNameCtrl = TextEditingController(text: p?.businessName);
_vatCtrl = TextEditingController(text: p?.vatNumber);
_cfCtrl = TextEditingController(text: p?.fiscalCode);
_sdiCtrl = TextEditingController(text: p?.sdiCode);
_pecCtrl = TextEditingController(text: p?.emailPec);
// Inizializziamo il Cubit appena la schermata si apre
WidgetsBinding.instance.addPostFrameCallback((_) {
// Recupero il companyId dall'utente loggato (Vigile Urbano)
final companyId = context
.read<SessionCubit>()
.state
.currentStore!
.companyId;
context.read<ProviderFormCubit>().initForm(
companyId: companyId,
existingProvider: widget.existingProvider,
);
});
}
@override
void dispose() {
_nameCtrl.dispose();
_businessNameCtrl.dispose();
_vatCtrl.dispose();
_cfCtrl.dispose();
_sdiCtrl.dispose();
_pecCtrl.dispose();
super.dispose();
}
void _flushControllers() {
context.read<ProviderFormCubit>().updateFields(
name: _nameCtrl.text.trim(),
businessName: _businessNameCtrl.text.trim(),
vatNumber: _vatCtrl.text.trim(),
fiscalCode: _cfCtrl.text.trim(),
sdiCode: _sdiCtrl.text.trim(),
emailPec: _pecCtrl.text.trim(),
);
}
@override
Widget build(BuildContext context) {
final isEditing = widget.existingProvider != null;
return BlocConsumer<ProviderFormCubit, ProviderFormState>(
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
if (state.status == ProviderFormStatus.success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fornitore salvato con successo!')),
);
Navigator.of(context).pop(); // Torna alla lista
} else if (state.status == ProviderFormStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
if (state.status == ProviderFormStatus.loading &&
state.provider.id == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: Text(isEditing ? 'Modifica Fornitore' : 'Nuovo Fornitore'),
actions: [
FilledButton.icon(
onPressed: () {
if (_formKey.currentState!.validate()) {
_flushControllers();
context.read<ProviderFormCubit>().save();
}
},
icon: const Icon(Icons.save),
label: const Text('Salva'),
),
const SizedBox(width: 16),
],
),
body: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildGeneralCard(context, state),
const SizedBox(height: 24),
_buildRolesCard(context, state),
const SizedBox(height: 24),
_buildFiscalCard(context),
const SizedBox(height: 24),
_buildStoresCard(context, state),
const SizedBox(height: 24),
_buildLocationsCard(context, state),
],
),
),
),
);
},
);
}
// --- CARD 1: DATI GENERALI ---
Widget _buildGeneralCard(BuildContext context, ProviderFormState state) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Dati Generali',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Nome Fornitore (Display Name) *',
prefixIcon: Icon(Icons.storefront),
),
validator: (val) =>
val == null || val.isEmpty ? 'Campo obbligatorio' : null,
),
const SizedBox(height: 16),
// Volendo qui puoi aggiungere lo Switch per "Attivo/Inattivo"
],
),
),
);
}
// --- CARD 2: RUOLI (I CHIPS NINJA) ---
Widget _buildRolesCard(BuildContext context, ProviderFormState state) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ruoli e Servizi',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const Text(
'Seleziona cosa fa questo fornitore (puoi sceglierne più di uno):',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 16),
Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: ProviderRole.values.map((role) {
final isSelected = state.provider.roles.contains(role);
return FilterChip(
label: Text(role.displayValue),
selected: isSelected,
selectedColor: role.color.withValues(alpha: 0.2),
checkmarkColor: role.color,
side: BorderSide(color: role.color.withValues(alpha: 0.3)),
onSelected: (bool selected) {
context.read<ProviderFormCubit>().toggleRole(role);
},
);
}).toList(),
),
],
),
),
);
}
// --- CARD 3: DATI FISCALI ---
Widget _buildFiscalCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Dati Fiscali (Per DDT e Fatturazione)',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
TextFormField(
controller: _businessNameCtrl,
decoration: const InputDecoration(
labelText: 'Ragione Sociale (es. Tech SpA)',
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _vatCtrl,
decoration: const InputDecoration(labelText: 'Partita IVA'),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _cfCtrl,
decoration: const InputDecoration(
labelText: 'Codice Fiscale',
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _sdiCtrl,
decoration: const InputDecoration(labelText: 'Codice SDI'),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _pecCtrl,
decoration: const InputDecoration(labelText: 'Email PEC'),
),
),
],
),
],
),
),
);
}
// --- CARD 4: NEGOZI ABILITATI ---
Widget _buildStoresCard(BuildContext context, ProviderFormState state) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Negozi Abilitati',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const Text(
'In quali punti vendita deve apparire questo fornitore?',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 16),
if (state.availableStores.isEmpty)
const Center(
child: Text(
'Nessun negozio trovato.',
style: TextStyle(fontStyle: FontStyle.italic),
),
)
else
...state.availableStores.map((storeMap) {
final storeId = storeMap['id'] as String;
final storeName = storeMap['name'] as String;
final isEnabled = state.selectedStoreIds.contains(storeId);
return SwitchListTile(
title: Text(storeName),
value: isEnabled,
onChanged: (bool val) {
context.read<ProviderFormCubit>().toggleStore(storeId);
},
);
}),
],
),
),
);
}
Widget _buildLocationsCard(BuildContext context, ProviderFormState state) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Sedi e Laboratori',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton.filledTonal(
onPressed: () async {
ProviderFormCubit providerFormCubit = context
.read<ProviderFormCubit>();
final res = await showDialog<ProviderLocationModel?>(
context: context,
builder: (context) => const ProviderLocationDialog(),
);
if (res != null) {
// Chiama il cubit per aggiungere localmente
providerFormCubit.addLocationLocal(res);
}
},
icon: const Icon(Icons.add_location_alt),
),
],
),
const SizedBox(height: 16),
if (state.localLocations.isEmpty)
const Text(
'Nessun indirizzo di spedizione inserito.',
style: TextStyle(fontStyle: FontStyle.italic),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: state.localLocations.length,
itemBuilder: (context, index) {
final loc = state.localLocations[index];
return ListTile(
leading: Icon(
loc.isMain ? Icons.star : Icons.location_on,
color: loc.isMain ? Colors.amber : null,
),
title: Text(loc.name),
subtitle: Text(
'${loc.address}, ${loc.city} (${loc.province})',
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => context
.read<ProviderFormCubit>()
.removeLocationLocal(index),
),
);
},
),
],
),
),
);
}
}

View File

@@ -1,214 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/master_data/providers/models/provider_model.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
class ProviderFormSheet extends StatefulWidget {
final ProviderModel? initialProvider;
const ProviderFormSheet({super.key, this.initialProvider});
@override
State<ProviderFormSheet> createState() => _ProviderFormSheetState();
}
class _ProviderFormSheetState extends State<ProviderFormSheet> {
late TextEditingController _nameController;
late bool _landline;
late bool _mobile;
late bool _energy;
late bool _insurance;
late bool _entertainment;
late bool _financing;
late bool _telepass;
late bool _other;
late bool _isActive;
final List<String> _tempSelectedStoreIds =
[]; // Per gestire la selezione temporanea dei negozi
@override
void initState() {
super.initState();
final p = widget.initialProvider;
for (final store in p?.associatedStores ?? []) {
_tempSelectedStoreIds.add(store.id!);
}
_nameController = TextEditingController(text: p?.name ?? '');
_landline = p?.landline ?? false;
_mobile = p?.mobile ?? false;
_energy = p?.energy ?? false;
_insurance = p?.insurance ?? false;
_entertainment = p?.entertainment ?? false;
_financing = p?.financing ?? false;
_telepass = p?.telepass ?? false;
_other = p?.other ?? false;
_isActive = p?.isActive ?? true;
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
void _save() {
if (_nameController.text.trim().isEmpty) {
return;
}
final cubit = context.read<ProvidersCubit>();
final provider = ProviderModel(
id: widget.initialProvider?.id, // Se nullo, Supabase farà insert
name: _nameController.text.trim(),
landline: _landline,
mobile: _mobile,
energy: _energy,
insurance: _insurance,
entertainment: _entertainment,
financing: _financing,
telepass: _telepass,
other: _other,
isActive: _isActive,
companyId:
'', // Verrà ignorato dal toMap e gestito dal Cubit/SessionBloc se hai messo la logica lì
);
cubit.saveProvider(provider, _tempSelectedStoreIds);
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(
context,
).viewInsets.bottom, // Gestisce la tastiera
left: 16,
right: 16,
top: 16,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.initialProvider == null
? "Nuovo Provider"
: "Modifica Provider",
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
TextField(
controller: _nameController,
keyboardType: TextInputType.name,
decoration: const InputDecoration(
labelText: "Nome Gestore/Brand",
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
const Text(
"Servizi Abilitati",
style: TextStyle(fontWeight: FontWeight.bold),
),
_buildSwitch(
"Energia (Luce/Gas)",
_energy,
(v) => setState(() => _energy = v),
),
_buildSwitch(
"Telefonia Fissa",
_landline,
(v) => setState(() => _landline = v),
),
_buildSwitch(
"Telefonia Mobile",
_mobile,
(v) => setState(() => _mobile = v),
),
_buildSwitch(
"Assicurazioni",
_insurance,
(v) => setState(() => _insurance = v),
),
_buildSwitch(
"Intrattenimento",
_entertainment,
(v) => setState(() => _entertainment = v),
),
_buildSwitch(
"Finanziamenti",
_financing,
(v) => setState(() => _financing = v),
),
_buildSwitch(
"Telepass",
_telepass,
(v) => setState(() => _telepass = v),
),
_buildSwitch(
"Altro/Accessori",
_other,
(v) => setState(() => _other = v),
),
const Divider(),
_buildSwitch(
"Stato Attivo",
_isActive,
(v) => setState(() => _isActive = v),
),
const Divider(),
const Text(
"Abilita nei Negozi",
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
// Qui usiamo un BlocBuilder per prendere la lista di tutti i negozi della company
BlocBuilder<StoreCubit, StoreState>(
builder: (context, storeState) {
return Column(
children: storeState.stores.map((store) {
final isAssociated = _tempSelectedStoreIds.contains(
store.id,
);
return CheckboxListTile(
title: Text(store.name),
value: isAssociated,
onChanged: (val) {
setState(() {
if (val == true) {
_tempSelectedStoreIds.add(store.id!);
} else {
_tempSelectedStoreIds.remove(store.id);
}
});
},
);
}).toList(),
);
},
),
const SizedBox(height: 24),
ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: _save,
child: const Text("SALVA ANAGRAFICA"),
),
const SizedBox(height: 24),
],
),
),
);
}
Widget _buildSwitch(String title, bool value, Function(bool) onChanged) {
return SwitchListTile(
title: Text(title),
value: value,
onChanged: onChanged,
contentPadding: EdgeInsets.zero,
);
}
}

View File

@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart';
import 'package:flux/features/master_data/providers/models/provider_role.dart';
import 'package:go_router/go_router.dart';
class ProviderListScreen extends StatefulWidget {
const ProviderListScreen({super.key});
@override
State<ProviderListScreen> createState() => _ProviderListScreenState();
}
class _ProviderListScreenState extends State<ProviderListScreen> {
// Filtro attivo (null = tutti)
ProviderRole? _selectedFilter;
@override
void initState() {
super.initState();
// Chiamiamo il refresh quando entriamo (il currentStore serve per caricare quelli giusti)
final storeId = context.read<SessionCubit>().state.currentStore?.id;
if (storeId != null) {
context.read<ProviderListCubit>().loadProviders(storeId);
}
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width > 800;
// --- COSTRUIAMO I CHIP DEI FILTRI CON I COLORI ---
final filterChipsWidgets = [
FilterChip(
label: const Text(
'Tutti',
style: TextStyle(fontWeight: FontWeight.bold),
),
selected: _selectedFilter == null,
onSelected: (val) => setState(() => _selectedFilter = null),
),
...ProviderRole.values.map((role) {
return FilterChip(
label: Text(role.displayValue),
selected: _selectedFilter == role,
// Un po' di trasparenza al colore selezionato per non accecare
selectedColor: role.color.withValues(alpha: 0.2),
checkmarkColor: role.color,
// Bordo leggermente colorato per dare un hint visuale anche da spento
side: BorderSide(color: role.color.withValues(alpha: 0.3)),
onSelected: (val) {
setState(() => _selectedFilter = val ? role : null);
},
);
}),
];
return Scaffold(
appBar: AppBar(title: const Text('Gestione Fornitori')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () async {
final providerListCubit = context.read<ProviderListCubit>();
final storeId = context.read<SessionCubit>().state.currentStore?.id;
await context.pushNamed(Routes.providerForm);
if (!mounted || storeId == null) return;
providerListCubit.loadProviders(storeId);
},
icon: const Icon(Icons.add),
label: const Text('Nuovo Fornitore'),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// --- BARRA DEI FILTRI INTELLIGENTE ---
if (isDesktop)
// Desktop: Wrap multilinea con un bel padding
Padding(
padding: const EdgeInsets.all(16.0),
child: Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: filterChipsWidgets,
),
)
else
// Mobile: Scorrimento orizzontale compatto
SizedBox(
height: 60,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
itemCount: filterChipsWidgets.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) => filterChipsWidgets[index],
),
),
const Divider(height: 1),
// --- LISTA FORNITORI ---
Expanded(
child: BlocBuilder<ProviderListCubit, ProviderListState>(
builder: (context, state) {
if (state.status == ProviderListStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == ProviderListStatus.failure) {
return Center(
child: Text(
'Errore: ${state.errorMessage}',
style: const TextStyle(color: Colors.red),
),
);
}
final displayList = _selectedFilter == null
? state.providers
: state.providers
.where((p) => p.roles.contains(_selectedFilter))
.toList();
if (displayList.isEmpty) {
return const Center(child: Text('Nessun fornitore trovato.'));
}
return ListView.separated(
itemCount: displayList.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final provider = displayList[index];
// --- I CHIP COLORATI DELLA LISTA ---
final roleChips = Wrap(
spacing: 4,
runSpacing: 4,
children: provider.roles
.map(
(r) => Chip(
label: Text(
r.displayValue,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: r.color.withValues(
alpha: 0.9,
), // Testo colorato!
),
),
backgroundColor: r.color.withValues(
alpha: 0.1,
), // Sfondo pastello
side: BorderSide(
color: r.color.withValues(alpha: 0.2),
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
)
.toList(),
);
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
title: Row(
children: [
Text(
provider.name,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: provider.isActive ? null : Colors.grey,
decoration: provider.isActive
? null
: TextDecoration.lineThrough,
),
),
if (isDesktop) ...[
const SizedBox(width: 16),
Expanded(child: roleChips),
],
],
),
subtitle: isDesktop
? null
: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: roleChips,
),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await context.pushNamed(
Routes.providerForm,
extra: provider,
);
if (context.mounted) {
final storeId = context
.read<SessionCubit>()
.state
.currentStore
?.id;
if (storeId != null) {
context.read<ProviderListCubit>().loadProviders(
storeId,
);
}
}
},
);
},
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import '../models/provider_location_model.dart';
class ProviderLocationDialog extends StatefulWidget {
final ProviderLocationModel? initialLocation;
const ProviderLocationDialog({super.key, this.initialLocation});
@override
State<ProviderLocationDialog> createState() => _ProviderLocationDialogState();
}
class _ProviderLocationDialogState extends State<ProviderLocationDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late final TextEditingController _addressCtrl;
late final TextEditingController _cityCtrl;
late final TextEditingController _zipCtrl;
late final TextEditingController _provCtrl;
late final TextEditingController _contactCtrl;
bool _isMain = false;
@override
void initState() {
super.initState();
final l = widget.initialLocation;
_nameCtrl = TextEditingController(text: l?.name);
_addressCtrl = TextEditingController(text: l?.address);
_cityCtrl = TextEditingController(text: l?.city);
_zipCtrl = TextEditingController(text: l?.zipCode);
_provCtrl = TextEditingController(text: l?.province);
_contactCtrl = TextEditingController(text: l?.contactPerson);
_isMain = l?.isMain ?? false;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
widget.initialLocation == null
? 'Aggiungi Sede/Laboratorio'
: 'Modifica Sede',
),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Nome Sede (es. Laboratorio Sud) *',
),
validator: (v) => v!.isEmpty ? 'Obbligatorio' : null,
),
TextFormField(
controller: _addressCtrl,
decoration: const InputDecoration(labelText: 'Indirizzo *'),
validator: (v) => v!.isEmpty ? 'Obbligatorio' : null,
),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
controller: _cityCtrl,
decoration: const InputDecoration(labelText: 'Città *'),
validator: (v) => v!.isEmpty ? 'Obbligatorio' : null,
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _provCtrl,
decoration: const InputDecoration(labelText: 'Prov.'),
maxLength: 2,
),
),
],
),
TextFormField(
controller: _zipCtrl,
decoration: const InputDecoration(labelText: 'CAP *'),
keyboardType: TextInputType.number,
validator: (v) => v!.isEmpty ? 'Obbligatorio' : null,
),
TextFormField(
controller: _contactCtrl,
decoration: const InputDecoration(
labelText: 'Referente (opzionale)',
),
),
SwitchListTile(
title: const Text('Sede Principale'),
value: _isMain,
onChanged: (v) => setState(() => _isMain = v),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annulla'),
),
FilledButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
ProviderLocationModel newLocation = ProviderLocationModel(
id: widget.initialLocation?.id,
name: _nameCtrl.text.trim(),
address: _addressCtrl.text.trim(),
city: _cityCtrl.text.trim(),
zipCode: _zipCtrl.text.trim(),
province: _provCtrl.text.trim().toUpperCase(),
contactPerson: _contactCtrl.text.trim(),
isMain: _isMain,
companyId: widget.initialLocation?.companyId ?? '',
providerId: widget.initialLocation?.providerId ?? '',
);
// Restituiamo una mappa o un modello parziale (senza ID e FK che gestirà il Cubit)
Navigator.pop(context, newLocation);
}
},
child: const Text('Conferma'),
),
],
);
}
}

View File

@@ -1,180 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/master_data/providers/models/provider_model.dart';
import 'package:flux/features/master_data/providers/ui/provider_form_sheet.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
class ProvidersMasterDataScreen extends StatefulWidget {
const ProvidersMasterDataScreen({super.key});
@override
State<ProvidersMasterDataScreen> createState() =>
_ProvidersMasterDataScreenState();
}
class _ProvidersMasterDataScreenState extends State<ProvidersMasterDataScreen> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Anagrafica Provider")),
body: BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
if (state.isLoading && state.allProviders.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.allProviders.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Un'icona grande e stilizzata
Icon(
Icons.handshake_outlined,
size: 80,
color: Colors.indigo.withValues(alpha: 0.3),
),
const SizedBox(height: 24),
const Text(
"Nessun Provider configurato",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
const Text(
"Aggiungi i partner con cui collabori (es. Enel, WindTre, ecc.) per poter gestire i servizi e i mandati nei tuoi negozi.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 32),
// Un bel bottone centrato per chi non vuole usare il FAB in basso
ElevatedButton.icon(
onPressed: () => _showProviderForm(context, null),
icon: const Icon(Icons.add),
label: const Text("AGGIUNGI IL PRIMO PROVIDER"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
],
),
),
);
}
return ListView.separated(
itemCount: state.allProviders.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final provider = state.allProviders[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: provider.isActive
? Colors.green.shade100
: Colors.grey.shade300,
child: Icon(
Icons.business,
color: provider.isActive ? Colors.green : Colors.grey,
),
),
title: Text(
provider.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: _buildCardSubtitle(
provider,
), // Una funzione che costruisce il sottotitolo con i badge
trailing: const Icon(Icons.edit_outlined),
onTap: () => _showProviderForm(context, provider),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showProviderForm(context, null),
child: const Icon(Icons.add),
),
);
}
Widget _buildCardSubtitle(ProviderModel provider) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildProviderBadges(provider), // I badge che abbiamo fatto prima
const SizedBox(height: 4),
BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
// Un piccolo testo che indica il numero di store associati
// Nota: Dovrai assicurarti che il Cubit carichi queste info
return Text(
"Disponibile in ${provider.associatedStores.length} negozi",
style: TextStyle(
fontSize: 11,
color: Colors.indigo.withValues(alpha: 0.7),
),
);
},
),
],
);
}
// Visualizza i servizi abilitati per quel provider nella lista
Widget _buildProviderBadges(ProviderModel p) {
return Wrap(
spacing: 4,
children: [
if (p.landline || p.mobile) _smallTag("📞 Tel", Colors.blue),
if (p.energy) _smallTag("⚡ Energy", Colors.orange),
if (p.insurance) _smallTag("🛡️ Assic", Colors.teal),
if (p.entertainment) _smallTag("📺 Ent", Colors.red),
if (p.financing) _smallTag("💰 Fin", Colors.purple),
if (p.telepass) _smallTag("🏎️ Telepass", Colors.yellow),
if (p.other) _smallTag("📦 Altro", Colors.grey),
],
);
}
Widget _smallTag(String label, Color color) {
return Text(
label,
style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w600),
);
}
// DIALOG PER INSERIMENTO/MODIFICA
void _showProviderForm(BuildContext context, ProviderModel? provider) {
final providersCubit = context.read<ProvidersCubit>();
final storeCubit = context.read<StoreCubit>();
// Implementeremo qui il form con i vari SwitchListTile
// Per ora facciamo un segnaposto o passiamo a scriverlo seriamente
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (modalContext) => MultiBlocProvider(
providers: [
BlocProvider.value(value: providersCubit),
BlocProvider.value(value: storeCubit),
],
child: ProviderFormSheet(initialProvider: provider),
),
);
}
}

View File

@@ -12,7 +12,22 @@ class StaffCubit extends Cubit<StaffState> {
final StaffRepository _repository = GetIt.I.get<StaffRepository>(); final StaffRepository _repository = GetIt.I.get<StaffRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>(); final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
StaffCubit() : super(const StaffState()); StaffCubit() : super(const StaffState()) {
init();
}
Future<void> init() async {
emit(state.copyWith(status: StaffStatus.loading, error: null));
try {
final allStaff = await _repository.getStaffMembers(
_sessionCubit.state.company!.id!,
);
emit(state.copyWith(status: StaffStatus.success, allStaff: allStaff));
} catch (e) {
emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
}
}
// Carica tutto lo staff della compagnia // Carica tutto lo staff della compagnia
Future<void> loadAllStaff() async { Future<void> loadAllStaff() async {

View File

@@ -1,3 +1,4 @@
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/master_data/store/models/store_model.dart'; import 'package:flux/features/master_data/store/models/store_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
@@ -11,17 +12,40 @@ class StaffRepository {
// Prende tutto lo staff della Company (per l'Hub Anagrafiche) // Prende tutto lo staff della Company (per l'Hub Anagrafiche)
Future<List<StaffMemberModel>> getStaffMembers(String companyId) async { Future<List<StaffMemberModel>> getStaffMembers(String companyId) async {
final response = await _supabase final response = await _supabase
.from('staff_member') .from(Tables.staffMembers)
.select() .select('''
*,
store_assignments:${Tables.staffInStores} (
${Tables.stores}(*)
)
''')
.eq('company_id', companyId) .eq('company_id', companyId)
.order('name', ascending: true); .order('name', ascending: true);
return (response as List).map((s) => StaffMemberModel.fromMap(s)).toList(); return (response as List).map((s) => StaffMemberModel.fromMap(s)).toList();
} }
Future<StaffMemberModel?> getStaffMemberById(String staffId) async {
try {
final response = await _supabase
.from(Tables.staffMembers)
.select('''
*,
store_assignments:${Tables.staffInStores} (
${Tables.stores}(*)
)
''')
.eq('id', staffId)
.single();
return StaffMemberModel.fromMap(response);
} on Exception catch (e) {
throw ('Errore nel recupero del membro staff con ID $staffId: $e');
}
}
Future<StaffMemberModel> saveStaffMember(StaffMemberModel member) async { Future<StaffMemberModel> saveStaffMember(StaffMemberModel member) async {
final response = await _supabase final response = await _supabase
.from('staff_member') .from(Tables.staffMembers)
.upsert(member.toMap()) .upsert(member.toMap())
.select() .select()
.single(); .single();
@@ -64,7 +88,7 @@ class StaffRepository {
try { try {
await _supabase.auth.resetPasswordForEmail( await _supabase.auth.resetPasswordForEmail(
email, email,
redirectTo: 'https://flux-web-invite.marco-6ba.workers.dev/', redirectTo: resetPasswordUrl,
); );
} catch (e) { } catch (e) {
throw Exception("Errore nell'invio del link: $e"); throw Exception("Errore nell'invio del link: $e");
@@ -77,14 +101,14 @@ class StaffRepository {
// Qui facciamo una JOIN per avere i dati del membro partendo dalla tabella di giunzione // Qui facciamo una JOIN per avere i dati del membro partendo dalla tabella di giunzione
Future<List<StaffMemberModel>> getStaffMembersInStore(String storeId) async { Future<List<StaffMemberModel>> getStaffMembersInStore(String storeId) async {
final response = await _supabase final response = await _supabase
.from('staff_in_stores') .from(Tables.staffInStores)
.select( .select(
'staff_member (*)', '${Tables.staffMembers} (*)',
) // Prende tutti i campi della tabella staff_member collegata ) // Prende tutti i campi della tabella staff_member collegata
.eq('store_id', storeId); .eq('store_id', storeId);
return (response as List) return (response as List)
.map((item) => StaffMemberModel.fromMap(item['staff_member'])) .map((item) => StaffMemberModel.fromMap(item[Tables.staffMembers]))
.toList(); .toList();
} }
@@ -92,20 +116,20 @@ class StaffRepository {
// Qui facciamo una JOIN per avere i dati del membro partendo dalla tabella di giunzione // Qui facciamo una JOIN per avere i dati del membro partendo dalla tabella di giunzione
Future<List<StoreModel>> getStaffMemberStore(String staffId) async { Future<List<StoreModel>> getStaffMemberStore(String staffId) async {
final response = await _supabase final response = await _supabase
.from('staff_in_stores') .from(Tables.staffInStores)
.select( .select(
'store (*)', '${Tables.stores} (*)',
) // Prende tutti i campi della tabella store collegata ) // Prende tutti i campi della tabella store collegata
.eq('staff_member_id', staffId); .eq('staff_member_id', staffId);
return (response as List) return (response as List)
.map((item) => StoreModel.fromMap(item['store'])) .map((item) => StoreModel.fromMap(item[Tables.stores]))
.toList(); .toList();
} }
// 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,
}); });
@@ -114,7 +138,7 @@ class StaffRepository {
// Rimuove l'assegnazione // Rimuove l'assegnazione
Future<void> removeStaffFromStore(String staffId, String storeId) async { Future<void> removeStaffFromStore(String staffId, String storeId) async {
await _supabase await _supabase
.from('staff_in_stores') .from(Tables.staffInStores)
.delete() .delete()
.eq('staff_member_id', staffId) .eq('staff_member_id', staffId)
.eq('store_id', storeId); .eq('store_id', storeId);
@@ -125,7 +149,7 @@ class StaffRepository {
// Utility per pulire le assegnazioni esistenti prima di riscriverle // Utility per pulire le assegnazioni esistenti prima di riscriverle
Future<void> clearStoreAssignments(String staffId) async { Future<void> clearStoreAssignments(String staffId) async {
await _supabase await _supabase
.from('staff_in_stores') .from(Tables.staffInStores)
.delete() .delete()
.eq('staff_member_id', staffId); .eq('staff_member_id', staffId);
} }

View File

@@ -1,4 +1,6 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/features/master_data/store/models/store_model.dart';
// L'Enum magico e blindato per il sistema // L'Enum magico e blindato per il sistema
enum SystemRole { enum SystemRole {
@@ -26,6 +28,8 @@ class StaffMemberModel extends Equatable {
final SystemRole systemRole; final SystemRole systemRole;
final bool isActive; final bool isActive;
final bool hasJoined; final bool hasJoined;
final List<String> assignedStoreIds;
final List<StoreModel> assignedStores;
const StaffMemberModel({ const StaffMemberModel({
this.id, this.id,
@@ -38,6 +42,8 @@ class StaffMemberModel extends Equatable {
this.systemRole = SystemRole.user, this.systemRole = SystemRole.user,
this.isActive = true, this.isActive = true,
this.hasJoined = false, this.hasJoined = false,
this.assignedStoreIds = const [],
this.assignedStores = const [],
}); });
StaffMemberModel copyWith({ StaffMemberModel copyWith({
@@ -52,6 +58,8 @@ class StaffMemberModel extends Equatable {
SystemRole? systemRole, SystemRole? systemRole,
bool? isActive, bool? isActive,
bool? hasJoined, bool? hasJoined,
List<String>? assignedStoreIds,
List<StoreModel>? assignedStores,
}) { }) {
return StaffMemberModel( return StaffMemberModel(
id: id ?? this.id, id: id ?? this.id,
@@ -64,6 +72,8 @@ class StaffMemberModel extends Equatable {
systemRole: systemRole ?? this.systemRole, systemRole: systemRole ?? this.systemRole,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
hasJoined: hasJoined ?? this.hasJoined, hasJoined: hasJoined ?? this.hasJoined,
assignedStoreIds: assignedStoreIds ?? this.assignedStoreIds,
assignedStores: assignedStores ?? this.assignedStores,
); );
} }
@@ -79,6 +89,24 @@ class StaffMemberModel extends Equatable {
} }
factory StaffMemberModel.fromMap(Map<String, dynamic> map) { factory StaffMemberModel.fromMap(Map<String, dynamic> map) {
// 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota
final List<String> parsedAssignedStoreIds =
map['assigned_store_ids'] != null
? List<String>.from(map['assigned_store_ids'])
: [];
// 2. Mappiamo il JOIN degli store, se presente
List<StoreModel> storeList = [];
// Gestione del JSON proveniente dal Join nidificato (es. task_assignments -> staff_members)
if (map['store_assignments'] != null) {
storeList = (map['store_assignments'] as List)
.map((a) => a[Tables.stores])
.where((s) => s != null)
.map((s) => StoreModel.fromMap(s))
.toList();
}
return StaffMemberModel( return StaffMemberModel(
id: map['id'] as String?, id: map['id'] as String?,
companyId: map['company_id'] ?? '', companyId: map['company_id'] ?? '',
@@ -90,6 +118,8 @@ class StaffMemberModel extends Equatable {
systemRole: SystemRole.fromString(map['system_role']), systemRole: SystemRole.fromString(map['system_role']),
isActive: map['is_active'] ?? true, isActive: map['is_active'] ?? true,
hasJoined: map['has_joined'] ?? false, hasJoined: map['has_joined'] ?? false,
assignedStoreIds: parsedAssignedStoreIds,
assignedStores: storeList,
); );
} }
@@ -120,5 +150,7 @@ class StaffMemberModel extends Equatable {
systemRole, systemRole,
isActive, isActive,
hasJoined, hasJoined,
assignedStoreIds,
assignedStores,
]; ];
} }

View File

@@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; // Tuo percorso import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.dart'; import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
@@ -17,18 +17,16 @@ class StaffScreen extends StatefulWidget {
class _StaffScreenState extends State<StaffScreen> { class _StaffScreenState extends State<StaffScreen> {
String? _selectedStoreId; String? _selectedStoreId;
bool _showAllCompanyStaff = true; // Partiamo con la vista globale bool _showAllCompanyStaff = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Carichiamo subito tutto
context.read<StaffCubit>().loadAllStaff(); context.read<StaffCubit>().loadAllStaff();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 1. Peschiamo chi siamo noi e che poteri abbiamo
final myRole = context final myRole = context
.read<SessionCubit>() .read<SessionCubit>()
.state .state
@@ -36,12 +34,12 @@ class _StaffScreenState extends State<StaffScreen> {
?.systemRole; ?.systemRole;
final canManageStaff = final canManageStaff =
myRole == SystemRole.admin || myRole == SystemRole.manager; myRole == SystemRole.admin || myRole == SystemRole.manager;
return Scaffold( return Scaffold(
backgroundColor: context.background, backgroundColor: context.background,
appBar: AppBar( appBar: AppBar(
title: const Text("Anagrafica Personale"), title: const Text("Anagrafica Personale"),
actions: [ actions: [
// Toggle per vista Azienda / Negozio
Padding( Padding(
padding: const EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: 16),
child: FilterChip( child: FilterChip(
@@ -66,7 +64,7 @@ class _StaffScreenState extends State<StaffScreen> {
}, },
child: Column( child: Column(
children: [ children: [
// --- BARRA FILTRO NEGOZIO (Visibile solo se non 'Tutta l'Azienda') --- // BARRA FILTRO NEGOZIO
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
height: _showAllCompanyStaff ? 0 : 80, height: _showAllCompanyStaff ? 0 : 80,
@@ -75,7 +73,7 @@ class _StaffScreenState extends State<StaffScreen> {
: _buildStoreSelector(), : _buildStoreSelector(),
), ),
// --- LISTA PERSONALE --- // LISTA PERSONALE
Expanded( Expanded(
child: BlocBuilder<StaffCubit, StaffState>( child: BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) { builder: (context, state) {
@@ -87,17 +85,14 @@ class _StaffScreenState extends State<StaffScreen> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (list.isEmpty) { if (list.isEmpty) return _buildEmptyState();
return _buildEmptyState();
}
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: list.length, itemCount: list.length,
separatorBuilder: (_, _) => const SizedBox(height: 12), separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final member = list[index]; return _buildStaffCard(list[index]);
return _buildStaffCard(member);
}, },
); );
}, },
@@ -118,7 +113,6 @@ class _StaffScreenState extends State<StaffScreen> {
Widget _buildStoreSelector() { Widget _buildStoreSelector() {
return BlocBuilder<StoreCubit, StoreState>( return BlocBuilder<StoreCubit, StoreState>(
// Assumendo tu abbia uno StoreCubit
builder: (context, state) { builder: (context, state) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -146,6 +140,8 @@ class _StaffScreenState extends State<StaffScreen> {
?.systemRole; ?.systemRole;
final canManageStaff = final canManageStaff =
myRole == SystemRole.admin || myRole == SystemRole.manager; myRole == SystemRole.admin || myRole == SystemRole.manager;
final hasEmail = member.email != null && member.email!.trim().isNotEmpty;
return Card( return Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -156,7 +152,10 @@ class _StaffScreenState extends State<StaffScreen> {
contentPadding: const EdgeInsets.all(12), contentPadding: const EdgeInsets.all(12),
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: context.accent.withValues(alpha: 0.1), backgroundColor: context.accent.withValues(alpha: 0.1),
child: Text(member.name[0], style: TextStyle(color: context.accent)), child: Text(
member.name[0].toUpperCase(),
style: TextStyle(color: context.accent),
),
), ),
title: Text( title: Text(
member.name, member.name,
@@ -165,55 +164,65 @@ class _StaffScreenState extends State<StaffScreen> {
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (member.email != null && member.email!.isNotEmpty) if (hasEmail) Text(member.email!),
Text(member.email!),
Text( Text(
member.phoneNumber != null && member.phoneNumber!.isNotEmpty member.phoneNumber != null &&
member.phoneNumber!.trim().isNotEmpty
? member.phoneNumber! ? member.phoneNumber!
: "Nessun telefono", : "Nessun telefono",
), ),
], if (member.jobTitle != null &&
), member.jobTitle!.trim().isNotEmpty) ...[
trailing: Row( const SizedBox(height: 4),
mainAxisSize: MainAxisSize.min, Text(
children: [ 'Qualifica: ${member.jobTitle!}',
if (member.jobTitle != null && member.jobTitle!.isNotEmpty) ...[ style: TextStyle(
Text('Qualifica: ${member.jobTitle!}'), color: context.accent,
const SizedBox(width: 8), fontWeight: FontWeight.w500,
],
if (canManageStaff) ...[
const SizedBox(width: 8),
if (!member.hasJoined)
ElevatedButton.icon(
icon: const Icon(Icons.send),
label: const Text("Re-invia Invito (In Attesa)"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
onPressed: () {
// Chiama la funzione di reset password mascherata da invito
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
member.email!,
);
},
)
else
OutlinedButton.icon(
icon: const Icon(Icons.lock_reset),
label: const Text("Invia Reset Password"),
onPressed: () {
// Chiama LA STESSA IDENTICA FUNZIONE!
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
member.email!,
);
},
), ),
),
], ],
], ],
), ),
// MODIFICA UX: Menu a tendina per le azioni (Salva spazio e previene overflow)
trailing: canManageStaff && hasEmail
? PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'invite_reset') {
context.read<StaffCubit>().resetPasswordOrResendInviteLink(
member.email!,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Operazione richiesta, controlla l\'email!',
),
),
);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'invite_reset',
child: Row(
children: [
Icon(
!member.hasJoined ? Icons.send : Icons.lock_reset,
size: 20,
),
const SizedBox(width: 12),
Text(
!member.hasJoined
? "Re-invia Invito"
: "Reset Password",
),
],
),
),
],
)
: null,
onTap: () => onTap: () =>
canManageStaff ? _openStaffForm(context, member: member) : null, canManageStaff ? _openStaffForm(context, member: member) : null,
), ),
@@ -226,7 +235,6 @@ class _StaffScreenState extends State<StaffScreen> {
final phoneController = TextEditingController(text: member?.phoneNumber); final phoneController = TextEditingController(text: member?.phoneNumber);
final jobTitleController = TextEditingController(text: member?.jobTitle); final jobTitleController = TextEditingController(text: member?.jobTitle);
// Variabili di stato per il BottomSheet
SystemRole selectedRole = member?.systemRole ?? SystemRole.user; SystemRole selectedRole = member?.systemRole ?? SystemRole.user;
List<String> tempSelectedStores = List<String> tempSelectedStores =
context context
@@ -263,7 +271,7 @@ class _StaffScreenState extends State<StaffScreen> {
children: [ children: [
Text( Text(
member == null member == null
? "Invita Collaboratore" // Cambiato il titolo per chiarezza! ? "Invita Collaboratore"
: "Modifica Collaboratore", : "Modifica Collaboratore",
style: const TextStyle( style: const TextStyle(
fontSize: 20, fontSize: 20,
@@ -279,16 +287,13 @@ class _StaffScreenState extends State<StaffScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Reso visivamente obbligatorio se è un nuovo utente
FluxTextField( FluxTextField(
controller: emailController, controller: emailController,
label: member == null label: member == null
? "Email (Obbligatoria per invito)*" ? "Email (Obbligatoria per invito)*"
: "Email", : "Email",
icon: Icons.email, icon: Icons.email,
enabled: enabled: member == null,
member ==
null, // UX: Di solito l'email non si cambia dopo l'invito
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -299,7 +304,6 @@ class _StaffScreenState extends State<StaffScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// --- NOVITÀ: SCELTA DEL RUOLO E MANSIONE ---
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -382,7 +386,6 @@ class _StaffScreenState extends State<StaffScreen> {
height: 50, height: 50,
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
// Validazione di base per i nuovi inviti
if (member == null && if (member == null &&
emailController.text.trim().isEmpty) { emailController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -396,7 +399,7 @@ class _StaffScreenState extends State<StaffScreen> {
} }
final updatedMember = StaffMemberModel( final updatedMember = StaffMemberModel(
id: member?.id, // Sarà null se è nuovo id: member?.id,
name: nameController.text.trim(), name: nameController.text.trim(),
email: emailController.text.trim(), email: emailController.text.trim(),
phoneNumber: phoneController.text.trim(), phoneNumber: phoneController.text.trim(),
@@ -410,17 +413,12 @@ class _StaffScreenState extends State<StaffScreen> {
userId: GetIt.I.get<SessionCubit>().state.user!.id, userId: GetIt.I.get<SessionCubit>().state.user!.id,
); );
// --- IL BIVIO LOGICO MAGICO ---
if (member == null) { if (member == null) {
// 1. UTENTE NUOVO -> Chiamiamo la Edge Function
// (Nota: Per i negozi, potresti dover fare una logica a parte nel Cubit
// perché l'ID del database viene generato DOPO che l'Edge Function ha finito)
context.read<StaffCubit>().inviteStaffMember( context.read<StaffCubit>().inviteStaffMember(
member: updatedMember, member: updatedMember,
selectedStoreIds: tempSelectedStores, selectedStoreIds: tempSelectedStores,
); );
} else { } else {
// 2. UTENTE ESISTENTE -> Modifica classica
context.read<StaffCubit>().saveStaffWithStores( context.read<StaffCubit>().saveStaffWithStores(
member: updatedMember, member: updatedMember,
selectedStoreIds: tempSelectedStores, selectedStoreIds: tempSelectedStores,
@@ -434,32 +432,6 @@ class _StaffScreenState extends State<StaffScreen> {
), ),
), ),
), ),
/* const SizedBox(height: 16),
if (!member!.hasJoined)
ElevatedButton.icon(
icon: const Icon(Icons.send),
label: const Text("Re-invia Invito (In Attesa)"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
onPressed: () {
// Chiama la funzione di reset password mascherata da invito
context
.read<StaffCubit>()
.resetPasswordOrResendInviteLink(member.email!);
},
)
else
OutlinedButton.icon(
icon: const Icon(Icons.lock_reset),
label: const Text("Invia Reset Password"),
onPressed: () {
// Chiama LA STESSA IDENTICA FUNZIONE!
context
.read<StaffCubit>()
.resetPasswordOrResendInviteLink(member.email!);
},
), */
], ],
), ),
), ),

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