106 Commits

Author SHA1 Message Date
123c006a1e changed navigation 2026-05-24 10:25:16 +02:00
415811f592 app shell 2026-05-24 09:49:07 +02:00
31066a4d8f v
All checks were successful
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m28s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m2s
Build and Release FLUX (Multi-Platform) / build-windows (push) Successful in 4m1s
2026-05-23 17:16:51 +02:00
b700c2de8d w
Some checks failed
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 3m4s
Build and Release FLUX (Multi-Platform) / build-android (push) Failing after 15m38s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m4s
2026-05-23 16:50:56 +02:00
fda5b8fe2e v
Some checks failed
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m24s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m2s
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 3m56s
2026-05-23 13:46:27 +02:00
b7a525056a v
Some checks failed
Build and Release FLUX (Multi-Platform) / build-windows (push) Failing after 1m58s
Build and Release FLUX (Multi-Platform) / build-android (push) Successful in 1m56s
Build and Release FLUX (Multi-Platform) / build-web (push) Successful in 1m30s
2026-05-23 11:08:59 +02:00
7a11e829b3 a 2026-05-23 11:08:39 +02:00
361b61a694 pub upgrade 2026-05-23 10:18:20 +02:00
0cb060c89c aggiunta build web e android
Some checks failed
Build and Release FLUX (Multi-Platform) / build-windows (push) Has been cancelled
Build and Release FLUX (Multi-Platform) / build-android (push) Has been cancelled
Build and Release FLUX (Multi-Platform) / build-web (push) Has been cancelled
2026-05-22 11:44:41 +02:00
4b9cbf65f9 using dep override pdfx da github
Some checks failed
Build and Release FLUX Windows / build (push) Has been cancelled
2026-05-22 11:06:19 +02:00
813fc9dd38 prova
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 26s
2026-05-22 10:45:46 +02:00
f574d6197b provato ad aggiustare pipeline windows
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 49s
2026-05-22 10:35:52 +02:00
2fac3117a4 version 2026-05-22 10:23:10 +02:00
7b072a219d feat notes 2026-05-22 10:12:56 +02:00
23d3356e6b fg 2026-05-21 19:29:46 +02:00
5b2702daed notes 2026-05-21 14:43:47 +02:00
b9c3eb7091 Merge branch 'main' into feat-notes
ho fatto delle modifiche al main necessarie anche di qui
2026-05-20 20:06:34 +02:00
6fbc5d947c df
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 41s
2026-05-20 17:21:11 +02:00
f520a02226 v
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 29s
2026-05-20 16:48:09 +02:00
3a43b2672a v 2026-05-20 16:48:00 +02:00
61959a5a2e v
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 42s
2026-05-20 16:41:23 +02:00
5f16ee2b38 r 2026-05-20 16:38:21 +02:00
a8ebb1dada df 2026-05-20 16:13:35 +02:00
862719b8b0 fd
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 28s
2026-05-20 16:12:32 +02:00
d1ee6d8a10 a
Some checks failed
Build and Release FLUX Windows / build (push) Has been cancelled
2026-05-20 15:58:04 +02:00
c3268012a5 prova auto build
Some checks failed
Build and Release FLUX Windows / build (push) Failing after 26s
2026-05-20 14:24:49 +02:00
da24b6a5ed impostato per auto build & release windows sul pc bancone 2026-05-20 14:20:10 +02:00
8b8dd0a427 i 2026-05-20 12:08:10 +02:00
979ab5e86d a 2026-05-20 11:07:29 +02:00
9703cb5ce8 d 2026-05-20 11:04:02 +02:00
c85f4b086e refactor nomi tabelle 2026-05-20 11:03:33 +02:00
f190ad9353 g 2026-05-19 19:28:24 +02:00
659963beb0 controllo versione 2026-05-19 18:53:24 +02:00
d3b1e52d88 f 2026-05-19 17:35:18 +02:00
3c0880f527 fix SharedAttachmentSection windows 2026-05-19 17:16:51 +02:00
8a1b582f4e fixes 2026-05-19 16:00:40 +02:00
364474471c fix status colors 2026-05-19 13:39:19 +02:00
3ecf617998 j 2026-05-19 12:46:13 +02:00
3f2f55d6c2 auto isBusiness based on customer selected 2026-05-19 12:03:03 +02:00
4e03d52a5d aggiunta scelta business o privato 2026-05-19 11:54:59 +02:00
2bdba523ad upgraded flutter and refactor document sequences 2026-05-19 11:17:28 +02:00
716de36bfa cambiata visualizzazione ultrawide di operation form screen 2026-05-19 10:41:13 +02:00
00d5890a37 rifatta operation form e diverse migliorie generali 2026-05-19 10:32:01 +02:00
ecb161bc07 b 2026-05-18 19:20:38 +02:00
1ee4a3bf45 fix 2026-05-18 12:57:07 +02:00
5e99324201 refactor shipping attachments and changed shipment to shipping for coherence 2026-05-18 12:00:07 +02:00
b06a655bc3 msi 2026-05-18 10:02:07 +02:00
906265a0e3 k 2026-05-18 08:31:39 +02:00
1a21b44bc8 df 2026-05-16 19:34:33 +02:00
a8c9e0f253 g 2026-05-16 16:39:56 +02:00
491a857f61 d 2026-05-16 15:49:46 +02:00
b3f463b688 fix macos pdf 2026-05-16 14:30:23 +02:00
9a5d0e33bd stampa ddt 2026-05-16 11:51:26 +02:00
a166992b04 refined document sequence management 2026-05-16 09:04:18 +02:00
b5ccb0428d fdsg 2026-05-15 19:18:03 +02:00
f4a8314978 f 2026-05-15 13:32:34 +02:00
f19f19a279 refactor providers e basi per spedizioni 2026-05-15 10:12:05 +02:00
ad35f641b3 k 2026-05-14 19:22:02 +02:00
6c892bf580 d 2026-05-14 19:16:13 +02:00
89099c2cfd lavorazione dei ticket 2026-05-14 15:59:46 +02:00
0f9616f19a d 2026-05-14 12:07:05 +02:00
3b3cfb5e43 f 2026-05-13 19:24:25 +02:00
24004a99da fix routing 2026-05-13 18:12:08 +02:00
ab7601a74e icons 2026-05-13 16:35:34 +02:00
f09606e1f7 fix isSingleUserMode inflated in SessionCubit 2026-05-13 15:55:06 +02:00
c610d68b9c added singleUserMode and removed StaffSection from forms 2026-05-13 15:41:35 +02:00
efb82b0d4a uff 2026-05-13 12:41:07 +02:00
216fd85888 a 2026-05-12 12:36:50 +02:00
2aab70aec5 sistemati ticket 2026-05-12 11:14:48 +02:00
57061da20d a 2026-05-11 20:44:17 +02:00
cbc5387097 w 2026-05-11 18:35:53 +02:00
e52dbee835 mmm 2026-05-11 18:19:48 +02:00
1dee51a7cd prova con metodo pdf vecchio programma assistenza 2026-05-11 17:13:57 +02:00
a76180497e boh 2026-05-11 11:44:14 +02:00
5c86483563 ticket labels e ticket receipt 2026-05-10 14:09:57 +02:00
385c3da0a5 named router with constants to prevent silent bugs 2026-05-09 20:42:42 +02:00
5f39d5b1ad change routes with names 2026-05-09 19:32:40 +02:00
1081609530 fix router 2026-05-09 17:30:51 +02:00
901f63841f d 2026-05-09 16:00:40 +02:00
27a262b54a changed image upload screen from mobile upload screen 2026-05-09 16:00:06 +02:00
a81515e4d8 dialog qr si chiude quando upload finito 2026-05-09 11:43:54 +02:00
73c5751677 Refactor QrUploadDialog to integrate BlocListener for attachment state management 2026-05-09 11:30:02 +02:00
0171ee6141 Refactor camera handling to remove image quality setting and streamline processing logic 2026-05-09 11:24:08 +02:00
1ee2758756 Improve camera image processing with overlay and error handling 2026-05-09 11:07:38 +02:00
fbb21dd8a4 f 2026-05-09 11:03:12 +02:00
45d49b38f7 tolto upsert nel upload documenti (cozzava con rls) 2026-05-09 10:54:49 +02:00
91a7663681 f 2026-05-09 10:20:53 +02:00
302bec114f fix 2026-05-09 10:08:29 +02:00
65aa3c7de8 fix mobile upload 2026-05-09 09:50:20 +02:00
c6ef798b22 dfa 2026-05-08 18:51:28 +02:00
42a9506f02 j 2026-05-08 12:28:14 +02:00
9793ba8348 a 2026-05-07 19:29:39 +02:00
fbf18acf05 df 2026-05-07 18:37:25 +02:00
5c1f9c0ebc upload ticket files 2026-05-07 18:08:45 +02:00
4cc1c9d157 df
Some checks failed
Deploy to Cloudflare Pages / build-and-deploy (push) Has been cancelled
2026-05-07 16:32:26 +02:00
7d03d0dea5 feat-tickets (#14)
Some checks failed
Deploy to Cloudflare Pages / build-and-deploy (push) Has been cancelled
Reviewed-on: #14
Co-authored-by: mark-cachy <marco@catelli.it>
Co-committed-by: mark-cachy <marco@catelli.it>
2026-05-07 16:28:01 +02:00
94ad524bae reworked operation (#12)
Reviewed-on: #12
Co-authored-by: Mark M2 Macbook <marco@catelli.it>
Co-committed-by: Mark M2 Macbook <marco@catelli.it>
2026-05-04 15:36:42 +02:00
9f57207a39 ok, design pulito e gorouter perfezionato (#11)
Reviewed-on: #11
Co-authored-by: Mark M2 Macbook <marco@catelli.it>
Co-committed-by: Mark M2 Macbook <marco@catelli.it>
2026-04-29 11:40:17 +02:00
1dff8ab90d added password reset - resend invite link (#10)
Co-authored-by: Copilot <copilot@github.com>
Reviewed-on: #10
Co-authored-by: Mark M2 Macbook <marco@catelli.it>
Co-committed-by: Mark M2 Macbook <marco@catelli.it>
2026-04-29 09:20:17 +02:00
fe5d1bd9e4 win 2026-04-28 18:06:15 +02:00
d2ff2de673 Merge pull request 'feat-invite_staff' (#9) from feat-invite_staff into main
Reviewed-on: #9
2026-04-28 15:34:33 +02:00
32c97accd7 ottimo
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 15:33:38 +02:00
f86b52e236 fix 2026-04-28 11:05:03 +02:00
c49964e5c5 fixes
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 10:12:15 +02:00
77987258aa import inutile 2026-04-26 16:30:18 +02:00
6cbf0479a1 urca non ci credo, potrebbe già funzionare
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 16:29:31 +02:00
268 changed files with 20804 additions and 8358 deletions

View File

@@ -0,0 +1,90 @@
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 }}
# -----------------------------------------------------------------
# JOB 3: WEB & CLOUDFLARE DEPLOY (Gira sul tuo MacBook)
# -----------------------------------------------------------------
build-web:
runs-on: macos-runner # <--- Etichetta del tuo Mac
steps:
- name: Checkout del codice
uses: actions/checkout@v3
- name: Crea file .env
run: |
cat << 'EOF' > .env
${{ secrets.ENV_FILE_CONTENT }}
EOF
- name: Build Flutter Web
run: flutter build web --release
# Sfruttiamo npx (incluso in Node.js) per lanciare wrangler al volo senza installarlo globalmente
# Sto assumendo che usi Cloudflare Pages che è perfetto per Flutter Web statico
- name: Deploy su Cloudflare Pages
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler pages deploy build/web --project-name="flux" --branch="main"

3
.gitignore vendored
View File

@@ -3,9 +3,10 @@
*.log *.log
*.pyc *.pyc
*.swp *.swp
*.env
.DS_Store .DS_Store
.atom/ .atom/
.build/ .build/*
.buildlog/ .buildlog/
.history .history
.svn/ .svn/

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

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

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

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

View File

@@ -1 +1,16 @@
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
# Escludiamo i file generati per le lingue, così il linter non ci entra proprio
- "lib/generated/**"
- "lib/l10n/*.dart"
- "**/*.g.dart" # Già che ci siamo escludiamo tutti i file generati (tipo quelli di JsonSerializable)
- "**/*.freezed.dart"
linter:
rules:
diagnostic_describe_all_properties: false
public_member_api_docs: false
# Ti consiglio di aggiungere anche questa se usi molto i file generati
avoid_relative_lib_imports: true

View File

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

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

3
l10n.yaml Normal file
View File

@@ -0,0 +1,3 @@
arb-dir: lib/l10n
template-arb-file: app_it.arb
output-localization-file: app_localizations.dart

View File

@@ -39,8 +39,35 @@ class SessionCubit extends Cubit<SessionState> {
} }
try { try {
// 1. CHI È QUESTO UTENTE? (Vediamo se ha un profilo staff, che sia Invitato o Admin)
StaffMemberModel? staff = await _repository.getStaffMemberByUserId(
user.id,
);
CompanyModel? company;
if (staff != null) {
// --- LA MAGIA DEL SENSORE ---
if (staff.hasJoined == false) {
// È la primissima volta che entra! Aggiorniamo il DB.
await _repository.updateStaffMember(staff.id!, {'has_joined': true});
// Aggiorniamo anche il nostro modello in memoria per questa sessione
staff = staff.copyWith(hasJoined: true);
}
company = await _repository.getCompanyById(staff.companyId);
} else {
// È l'Admin in onboarding
company = await _repository.getCompanyByOwnerId(user.id);
}
// 1. Controllo Azienda // 1. Controllo Azienda
final company = await _repository.getCompanyByOwnerId(user.id);
if (staff != null) {
// L'utente esiste già nel sistema! Carichiamo l'azienda per cui lavora
company = await _repository.getCompanyById(staff.companyId);
} else {
// L'utente non ha profilo. Probabilmente è l'Admin che ha appena
// fatto Sign Up e sta iniziando l'Onboarding
company = await _repository.getCompanyByOwnerId(user.id);
}
if (company == null) { if (company == null) {
return emit( return emit(
state.copyWith( state.copyWith(
@@ -69,7 +96,6 @@ class SessionCubit extends Cubit<SessionState> {
} }
// 3. Controllo Staff (Paziente Zero) // 3. Controllo Staff (Paziente Zero)
final staff = await _repository.getStaffMemberByUserId(user.id);
if (staff == null) { if (staff == null) {
return emit( return emit(
state.copyWith( state.copyWith(
@@ -95,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(
@@ -102,7 +130,7 @@ class SessionCubit extends Cubit<SessionState> {
user: user, user: user,
company: company, company: company,
currentStore: activeStore, currentStore: activeStore,
currentStaff: staff, currentStaffMember: staff,
onboardingStep: OnboardingStep.none, // Svuotiamo l'onboarding onboardingStep: OnboardingStep.none, // Svuotiamo l'onboarding
), ),
); );
@@ -117,6 +145,10 @@ class SessionCubit extends Cubit<SessionState> {
} }
} }
void updateCurrentCompany(CompanyModel newCompany) {
emit(state.copyWith(company: newCompany));
}
// --- FUNZIONE EXTRA: CAMBIO NEGOZIO DALLA DASHBOARD --- // --- FUNZIONE EXTRA: CAMBIO NEGOZIO DALLA DASHBOARD ---
Future<void> changeStore(StoreModel newStore) async { Future<void> changeStore(StoreModel newStore) async {
if (newStore.id != null) { if (newStore.id != null) {
@@ -134,4 +166,8 @@ class SessionCubit extends Cubit<SessionState> {
void setIsMobileDevice(bool isMobile) { void setIsMobileDevice(bool isMobile) {
emit(state.copyWith(isMobileDevice: isMobile)); emit(state.copyWith(isMobileDevice: isMobile));
} }
void setIsSingleUserMode(bool isSingleUser) {
emit(state.copyWith(isSingleUserMode: isSingleUser));
}
} }

View File

@@ -22,18 +22,20 @@ class SessionState extends Equatable {
final User? user; // Utente di Supabase Auth final User? user; // Utente di Supabase Auth
final CompanyModel? company; final CompanyModel? company;
final StoreModel? currentStore; final StoreModel? currentStore;
final StaffMemberModel? currentStaff; final StaffMemberModel? currentStaffMember;
final OnboardingStep onboardingStep; final OnboardingStep onboardingStep;
final bool isMobileDevice; final bool isMobileDevice;
final bool isSingleUserMode;
const SessionState({ const SessionState({
this.status = SessionStatus.initial, this.status = SessionStatus.initial,
this.user, this.user,
this.company, this.company,
this.currentStore, this.currentStore,
this.currentStaff, this.currentStaffMember,
this.onboardingStep = OnboardingStep.none, this.onboardingStep = OnboardingStep.none,
this.isMobileDevice = false, this.isMobileDevice = false,
this.isSingleUserMode = false,
}); });
/// Metodo per creare una copia dello stato modificando solo i campi necessari /// Metodo per creare una copia dello stato modificando solo i campi necessari
@@ -42,18 +44,20 @@ class SessionState extends Equatable {
User? user, User? user,
CompanyModel? company, CompanyModel? company,
StoreModel? currentStore, StoreModel? currentStore,
StaffMemberModel? currentStaff, StaffMemberModel? currentStaffMember,
OnboardingStep? onboardingStep, OnboardingStep? onboardingStep,
bool? isMobileDevice, bool? isMobileDevice,
bool? isSingleUserMode,
}) { }) {
return SessionState( return SessionState(
status: status ?? this.status, status: status ?? this.status,
user: user ?? this.user, user: user ?? this.user,
company: company ?? this.company, company: company ?? this.company,
currentStore: currentStore ?? this.currentStore, currentStore: currentStore ?? this.currentStore,
currentStaff: currentStaff ?? this.currentStaff, currentStaffMember: currentStaffMember ?? this.currentStaffMember,
onboardingStep: onboardingStep ?? this.onboardingStep, onboardingStep: onboardingStep ?? this.onboardingStep,
isMobileDevice: isMobileDevice ?? this.isMobileDevice, isMobileDevice: isMobileDevice ?? this.isMobileDevice,
isSingleUserMode: isSingleUserMode ?? this.isSingleUserMode,
); );
} }
@@ -63,9 +67,10 @@ class SessionState extends Equatable {
user, user,
company, company,
currentStore, currentStore,
currentStaff, currentStaffMember,
onboardingStep, onboardingStep,
isMobileDevice, isMobileDevice,
isSingleUserMode,
]; ];
// Helper rapidi per la UI // Helper rapidi per la UI

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();
@@ -24,30 +25,45 @@ class CoreRepository {
return CompanyModel.fromMap(response); return CompanyModel.fromMap(response);
} catch (e) { } catch (e) {
debugPrint('Errore recupero azienda: $e'); debugPrint('Errore recupero azienda: $e');
throw Exception('Errore recupero azienda: $e'); throw Exception('$e');
}
}
Future<CompanyModel?> getCompanyById(String companyId) async {
try {
final response = await _supabase
.from(Tables.companies)
.select()
.eq('id', companyId)
.maybeSingle();
if (response == null) return null;
return CompanyModel.fromMap(response);
} catch (e) {
debugPrint('$e');
return null;
} }
} }
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
.order('nome'); // O come si chiama il campo nome .order('name'); // O come si chiama il campo nome
return (response as List).map((s) => StoreModel.fromMap(s)).toList(); return (response as List).map((s) => StoreModel.fromMap(s)).toList();
} catch (e) { } catch (e) {
debugPrint('Errore recupero negozi: $e'); debugPrint('Errore recupero negozi: $e');
throw Exception('Errore recupero negozi: $e'); throw Exception('$e');
} }
} }
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();
@@ -56,7 +72,7 @@ class CoreRepository {
return StaffMemberModel.fromMap(response); return StaffMemberModel.fromMap(response);
} catch (e) { } catch (e) {
debugPrint('Errore recupero profilo staff: $e'); debugPrint('Errore recupero profilo staff: $e');
throw Exception('Errore recupero profilo staff: $e'); throw Exception('$e');
} }
} }
@@ -65,47 +81,62 @@ 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();
return CompanyModel.fromMap(response); return CompanyModel.fromMap(response);
} catch (e) { } catch (e) {
debugPrint('Creazione azienda fallita: $e'); debugPrint('Creazione azienda fallita: $e');
throw Exception('Creazione azienda fallita: $e'); throw Exception('$e');
} }
} }
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();
return StoreModel.fromMap(response); return StoreModel.fromMap(response);
} catch (e) { } catch (e) {
debugPrint('Creazione negozio fallita: $e'); debugPrint('Creazione negozio fallita: $e');
throw Exception('Creazione negozio fallita: $e'); throw Exception('$e');
} }
} }
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,
}); });
return StaffMemberModel.fromMap(response); return StaffMemberModel.fromMap(response);
} catch (e) { } catch (e) {
debugPrint('Creazione profilo staff fallita: $e'); debugPrint('Creazione profilo staff fallita: $e');
throw Exception('Creazione profilo staff fallita: $e'); throw Exception('$e');
} }
} }
// Assegna un membro a un negozio
Future<void> assignStaffToStore(String staffId, String storeId) async {
await _supabase.from(Tables.staffInStores).insert({
'staff_member_id': staffId,
'store_id': storeId,
});
}
Future<void> updateStaffMember(
String staffId,
Map<String, dynamic> data,
) async {
await _supabase.from(Tables.staffMembers).update(data).eq('id', staffId);
}
} }

View File

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

View File

@@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:go_router/go_router.dart';
// ==========================================
// 1. IL GUSCIO (QUELLO CHE PASSI AL ROUTER)
// ==========================================
class AppShell extends StatelessWidget {
final Widget child;
const AppShell({super.key, required this.child});
@override
Widget build(BuildContext context) {
// Breakpoint a 900px: sotto è Mobile/Tablet (Drawer), sopra è Desktop (Sidebar)
final isDesktop = MediaQuery.sizeOf(context).width >= 900;
final currentPath = GoRouterState.of(context).uri.path;
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
? Row(
children: [
// Su desktop inietta il menu a sinistra!
AppMenu(currentPath: currentPath, isDrawer: false),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: child),
],
)
: child, // Su mobile il child prende tutto lo schermo sotto l'AppBar
);
}
}
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

@@ -1,23 +1,54 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
// Importa il tuo SessionCubit e lo State
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/data/core_repository.dart'; import 'package:flux/core/data/core_repository.dart';
import 'package:flux/features/customers/ui/customer_mobile_upload_screen.dart'; import 'package:flux/core/layout/app_shell.dart';
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/widgets/image_upload/blocs/image_upload_cubit.dart';
import 'package:flux/core/widgets/image_upload/ui/image_upload_screen.dart';
import 'package:flux/core/widgets/set_password_screen.dart';
import 'package:flux/core/widgets/image_upload/ui/upload_success_screen.dart';
import 'package:flux/features/auth/ui/auth_screen.dart'; import 'package:flux/features/auth/ui/auth_screen.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; import 'package:flux/features/company/bloc/company_settings_cubit.dart';
import 'package:flux/features/company/ui/company_settings_screen.dart';
import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
import 'package:flux/features/customers/blocs/customers_list_cubit.dart';
import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/customer_detail_screen.dart'; import 'package:flux/features/customers/ui/customer_detail_screen.dart';
import 'package:flux/features/customers/ui/customer_form_screen.dart';
import 'package:flux/features/customers/ui/customers_list_screen.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/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_form_cubit.dart';
import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.dart';
import 'package:flux/features/master_data/providers/models/provider_model.dart';
import 'package:flux/features/master_data/providers/ui/provider_form_screen.dart';
import 'package:flux/features/master_data/providers/ui/provider_list_screen.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/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/services/blocs/service_files_bloc.dart'; import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:flux/features/services/models/service_model.dart'; import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
import 'package:flux/features/services/ui/service_form_screen/service_form_screen.dart'; import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/services/ui/service_form_screen/service_mobile_upload_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/settings/settings_screen.dart';
import 'package:flux/features/settings/theme_settings_view.dart';
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
import 'package:flux/features/tickets/ui/ticket_list_screen.dart';
import 'package:flux/features/tickets/ui/ticket_workspace/ticket_workspace_screen.dart';
import 'package:flux/features/tracking/blocs/tracking_cubit.dart';
import 'package:flux/features/tracking/models/tracking_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -25,55 +56,63 @@ class AppRouter {
static GoRouter createRouter(SessionCubit sessionCubit) { static GoRouter createRouter(SessionCubit sessionCubit) {
return GoRouter( return GoRouter(
initialLocation: '/', initialLocation: '/',
// MAGIA 1: Il router "ascolta" ogni singolo respiro del SessionCubit
refreshListenable: GoRouterRefreshStream(sessionCubit.stream), refreshListenable: GoRouterRefreshStream(sessionCubit.stream),
// MAGIA 2: Il Buttafuori Supremo
redirect: (context, state) { redirect: (context, state) {
final sessionState = sessionCubit.state; final sessionState = sessionCubit.state;
final isGoingToLogin = state.matchedLocation == '/login'; final isGoingToLogin = state.matchedLocation == '/login';
final isGoingToOnboarding = state.matchedLocation == '/onboarding'; final isGoingToOnboarding = state.matchedLocation == '/onboarding';
final isGoingToSetPassword = state.matchedLocation == '/set-password';
// Caso 1: L'app si sta ancora avviando. // 1. LA PASSATOIA VIP (DEVE ESSERE IN CIMA)
// Restituiamo null per farlo rimanere sulla SplashScreen del main.dart // Usiamo state.uri.path perché state.matchedLocation a volte fa i capricci coi deep link iniziali
if (sessionState.status == SessionStatus.initial) { final isPublicRoute = state.uri.path.startsWith('/upload');
if (isPublicRoute) {
// Ritorna null esplicitamente per dire al router "Rimani qui e non fare altri controlli"
return null; return null;
} }
// Caso 2: Utente NON loggato. // 2. CONTROLLO INIZIALE
// Se la sessione sta ancora caricando la primissima volta (es. splash screen logico)
if (sessionState.status == SessionStatus.initial) return null;
// 3. UTENTE NON LOGGATO (Ma ci arriva solo se non è su /upload)
if (sessionState.status == SessionStatus.unauthenticated) { if (sessionState.status == SessionStatus.unauthenticated) {
// Se sta già andando al login, lascialo andare. Altrimenti, forzalo al login. // Se sta già andando alle uniche altre pagine pubbliche, lascialo andare
return isGoingToLogin ? null : '/login'; if (isGoingToLogin || isGoingToSetPassword) return null;
// Altrimenti bloccalo e mandalo al login
return '/login';
} }
// Caso 3: Utente loggato MA manca un pezzo dell'azienda (Flusso Canalizzatore) // 4. UTENTE LOGGATO MA DEVE COMPLETARE L'ONBOARDING
if (sessionState.status == SessionStatus.onboardingRequired) { if (sessionState.status == SessionStatus.onboardingRequired) {
// Se sta già andando all'onboarding, ok. Altrimenti forzalo lì.
// Non può "scappare" digitando l'URL della dashboard!
return isGoingToOnboarding ? null : '/onboarding'; return isGoingToOnboarding ? null : '/onboarding';
} }
// Caso 4: Utente loggato e configurato (Tutto OK!) // 5. UTENTE PERFETTAMENTE LOGGATO E OPERATIVO
if (sessionState.status == SessionStatus.authenticated) { if (sessionState.status == SessionStatus.authenticated) {
// Se per sbaglio cerca di tornare al login o all'onboarding, // Se per sbaglio cerca di tornare al login o all'onboarding, ributtalo in dashboard
// lo rimbalziamo alla home. if (isGoingToLogin || isGoingToOnboarding) return '/';
if (isGoingToLogin || isGoingToOnboarding) {
return '/';
}
// Per tutte le altre rotte (dashboard, clienti, anagrafiche), lascialo passare.
return null; return null;
} }
return null; return null;
}, },
routes: [ routes: [
// --- ROTTE DI SERVIZIO (FUORI DALLA SHELL) ---
GoRoute( GoRoute(
path: '/login', path: '/login',
//builder: (context, state) => const LoginScreen(), name: Routes.login,
builder: (context, state) => const AuthScreen(), builder: (context, state) => const AuthScreen(),
), ),
GoRoute(
path: '/set-password',
name: Routes.setPassword,
builder: (context, state) => const SetPasswordScreen(),
),
GoRoute( GoRoute(
path: '/onboarding', path: '/onboarding',
name: Routes.onboarding,
builder: (context, state) => BlocProvider( builder: (context, state) => BlocProvider(
create: (context) => OnboardingCubit( create: (context) => OnboardingCubit(
GetIt.I.get<SessionCubit>(), GetIt.I.get<SessionCubit>(),
@@ -81,79 +120,360 @@ class AppRouter {
), ),
child: const OnboardingScreen(), child: const OnboardingScreen(),
), ),
// Nota: All'interno di questa schermata useremo il PageView pilotato
// dall'OnboardingStep. Al router non interessa quale step è attivo,
// gli basta sapere che deve stare rinchiuso qui dentro!
), ),
GoRoute(
path: '/', // --- CORE APP (DENTRO LA SHELL CON NAVIGATION BAR/RAIL) ---
builder: (context, state) => const HomeScreen(), // La tua home ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
// ==========================================
// 1. DASHBOARD
// ==========================================
GoRoute(
path: '/',
name: Routes.home,
builder: (context, state) => const HomeScreen(),
),
// ==========================================
// 2. HUB ANAGRAFICHE E SOTTO-ROTTE
// ==========================================
GoRoute(
path: '/master-data',
name: Routes.masterData,
builder: (context, state) => const MasterDataHubScreen(),
routes: [
GoRoute(
path:
'customers', // Niente slash iniziale per le sottorotte! -> /master-data/customers
name: Routes.customers,
builder: (context, state) => const CustomersListScreen(),
),
GoRoute(
path: 'providers', // -> /master-data/providers
name: Routes.providers,
builder: (context, state) => const ProviderListScreen(),
),
GoRoute(
path: 'products', // -> /master-data/products
name: Routes.products,
builder: (context, state) {
context.read<ProductsCubit>().refreshCubit();
return const ProductsScreen();
},
),
GoRoute(
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) => const StoresScreen(),
),
GoRoute(
path: 'company-settings', // -> /master-data/company-settings
name: Routes.companySettings,
builder: (context, state) => BlocProvider(
create: (context) => CompanySettingsCubit(),
child: const CompanySettingsScreen(),
),
),
],
),
// ==========================================
// 3. IMPOSTAZIONI
// ==========================================
GoRoute(
path: '/settings',
name: Routes.settings,
builder: (context, state) => const SettingsScreen(),
routes: [
GoRoute(
path: 'themeSettings', // -> /settings/themeSettings
name: Routes.themeSettings,
builder: (context, state) => const ThemeSettingsView(),
),
],
),
// ==========================================
// 4. SCHERMATE PRINCIPALI EXTRA NELLA SHELL
// (Accessibili ad es. dalla dashboard, mantengono la sidebar)
// ==========================================
GoRoute(
path: '/operations',
name: Routes.operations,
builder: (context, state) => const OperationListScreen(),
),
GoRoute(
path: '/tickets',
name: Routes.tickets,
builder: (context, state) => const TicketListScreen(),
),
GoRoute(
path: '/notes',
name: Routes.notes,
builder: (context, state) => const NotesListScreen(),
),
],
), ),
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
GoRoute( GoRoute(
path: '/customer/:id', path: '/providers/form',
name: Routes.providerForm,
builder: (context, state) {
// Estraiamo il fornitore (se stiamo modificando e non creando)
final existingProvider = state.extra as ProviderModel?;
return BlocProvider<ProviderFormCubit>(
// Inizializziamo un Cubit NUOVO ogni volta che apriamo il form
create: (context) => ProviderFormCubit(),
child: ProviderFormScreen(existingProvider: existingProvider),
);
},
),
GoRoute(
// Il path sarà es. /tickets/form/123 oppure /tickets/form/new
path: '/tickets/form/:id',
name: Routes.ticketForm,
builder: (context, state) {
// 1. Leggiamo l'ID dall'URL
final String pathId = state.pathParameters['id'] ?? 'new';
// 2. CAST DA NINJA (Aggiungi i punti interrogativi!)
final record =
state.extra
as ({StaffMemberModel? createdBy, TicketModel? ticket})?;
// 3. LOGICA SOBRIA
final String? realTicketId;
if (pathId == 'new') {
realTicketId = null;
} else if (record?.ticket?.id != null) {
// <-- Parentesi TONDE per la condizione, GRAFFE per il blocco!
realTicketId = record!.ticket!.id;
} else {
realTicketId = pathId;
}
if (realTicketId != null) {
context.read<TrackingCubit>().loadTrackings(
realTicketId,
TrackingParentType.ticket,
);
}
context.read<CustomersListCubit>().loadCustomers();
context.read<ProductsCubit>().loadModels();
context.read<ProductsCubit>().loadBrands();
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => AttachmentsBloc(
parentType: AttachmentParentType.ticket,
parentId: realTicketId,
),
),
BlocProvider(
create: (context) => TicketFormCubit(
// Passiamo il creatore e l'eventuale ticket esistente presi dal Record!
createdBy: record?.createdBy,
existingTicket: record?.ticket,
),
),
],
child: TicketFormScreen(
ticketId: realTicketId,
existingTicket: record?.ticket,
),
);
},
),
GoRoute(
path: '/tickets/workspace/:id',
name: Routes.ticketWorkspace,
builder: (context, state) {
// 1. Recuperiamo il Cubit vivo dall'extra
final formCubit = state.extra as TicketFormCubit?;
// 2. Controllo di sicurezza (fondamentale per Flutter Web)
if (formCubit != null) {
return BlocProvider.value(
value: formCubit,
child: const TicketWorkspaceScreen(),
);
} else {
// SCENARIO REFRESH WEB:
// Se l'utente preme F5 del browser mentre è nel banco da lavoro,
// l'extra viene distrutto e diventa null.
// In questo caso, gli diciamo elegantemente che la sessione è persa
// e lo invitiamo a tornare indietro, oppure restituisci direttamente
// un blocco di redirect!
return const Scaffold(
body: Center(
child: Text(
'Sessione di lavoro scaduta. Torna alla lista e riapri il ticket.',
),
),
);
}
},
),
GoRoute(
path: '/upload-success',
name: Routes.uploadSuccess,
builder: (context, state) => const UploadSuccessScreen(),
),
GoRoute(
path: '/customer/details/:id',
name: Routes.customerDetails,
builder: (context, state) { builder: (context, state) {
// Recuperiamo l'oggetto customer passato tramite extra
final customer = state.extra as CustomerModel; final customer = state.extra as CustomerModel;
return BlocProvider( return BlocProvider(
create: (context) => CustomerFilesBloc(customer.id!), create: (context) => AttachmentsBloc(
parentType: AttachmentParentType.customer,
parentId: customer.id,
),
child: CustomerDetailScreen(customer: customer), child: CustomerDetailScreen(customer: customer),
); );
}, },
), ),
GoRoute( GoRoute(
path: '/customer/:id/upload', path: '/customer/form/:id',
name: Routes.customerForm,
builder: (context, state) { builder: (context, state) {
final customerId = state.pathParameters['id']!; final String pathId = state.pathParameters['id'] ?? 'new';
// Recuperiamo il nome dalle query se vogliamo mostrarlo nel titolo, final String? realCustomerId;
// oppure lo caricherà il bloc. if (pathId == 'new') {
final customerName = state.uri.queryParameters['name'] ?? 'Cliente'; realCustomerId = null;
} else {
realCustomerId = pathId;
}
final customer = state.extra as CustomerModel?;
return BlocProvider( return BlocProvider(
create: (context) => CustomerFilesBloc(customerId), create: (context) => CustomerFormCubit(
child: CustomerMobileUploadScreen( existingCustomer: customer,
customerId: customerId, customerId: realCustomerId,
customerName: customerName, ),
child: CustomerFormScreen(
customer: customer,
customerId: realCustomerId,
),
);
},
),
GoRoute(
path: '/operations/form/:id',
name: Routes.operationForm,
builder: (context, state) {
final String pathId = state.pathParameters['id'] ?? 'new';
final record =
state.extra
as ({
StaffMemberModel? createdBy,
OperationModel? operation,
})?;
final String? realOperationId;
if (pathId == 'new') {
realOperationId = null;
} else if (record?.operation?.id != null) {
realOperationId = record!.operation!.id;
} else {
realOperationId = pathId;
}
final currentStoreId = GetIt.I
.get<SessionCubit>()
.state
.currentStore!
.id!;
context.read<CustomersListCubit>().loadCustomers();
context.read<ProviderListCubit>().loadProviders(currentStoreId);
context.read<ProductsCubit>().loadModels();
context.read<ProductsCubit>().loadBrands();
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => AttachmentsBloc(
parentId: realOperationId,
parentType: AttachmentParentType.operation,
),
),
BlocProvider(
create: (context) => OperationFormCubit(
createdBy: record?.createdBy,
existingOperation: record?.operation,
),
),
],
child: OperationFormScreen(
operationId: realOperationId,
existingOperation: record?.operation,
),
);
},
),
GoRoute(
path: '/upload/:type/:id',
name: Routes.upload,
builder: (context, state) {
final typeString = state.pathParameters['type']!;
final id = state.pathParameters['id']!;
final companyId = state.uri.queryParameters['companyId']!;
// Trasformiamo la stringa dell'URL nel nostro amato Enum!
final parentType = AttachmentParentType.values.firstWhere(
(e) => e.name == typeString,
orElse: () =>
AttachmentParentType.ticket, // Fallback di sicurezza
);
// Creiamo il BLoC "al volo" solo per questa schermata
return MultiBlocProvider(
providers: [
BlocProvider<AttachmentsBloc>(
create: (context) =>
AttachmentsBloc(parentId: id, parentType: parentType),
),
BlocProvider(create: (context) => ImageUploadCubit()),
],
child: ImageUploadScreen(
title: 'Caricamento Rapido',
companyId: companyId,
), ),
); );
}, },
), ),
GoRoute( GoRoute(
path: '/products', path: '/notes/edit/:id',
name: 'products', name: Routes.noteForm,
builder: (context, state) => const ProductsScreen(),
),
GoRoute(
path: '/service-form',
name: 'service-form',
builder: (context, state) { builder: (context, state) {
// Recuperiamo l'oggetto se passato tramite 'extra' final id = state.pathParameters['id']!;
final existingService = state.extra as ServiceModel?; final NoteModel note = state.extra as NoteModel;
// Recuperiamo l'ID se presente nell'URL
final serviceId = state.uri.queryParameters['serviceId'];
return BlocProvider( // Creiamo il BLoC "al volo" solo per questa schermata
create: (context) => return MultiBlocProvider(
ServiceFilesBloc(serviceId: serviceId ?? existingService?.id), providers: [
child: ServiceFormScreen( BlocProvider<AttachmentsBloc>(
serviceId: serviceId ?? existingService?.id, create: (context) => AttachmentsBloc(
existingService: existingService, parentId: id,
), parentType: AttachmentParentType.note,
); ),
}, ),
), ],
GoRoute(
path: '/service/:id/upload',
builder: (context, state) {
final serviceId = state.pathParameters['id']!;
final serviceName = state.uri.queryParameters['name'] ?? 'Pratica';
return BlocProvider( child: NoteFormScreen(note: note),
// Inizializziamo il bloc col serviceId corretto!
create: (context) => ServiceFilesBloc(serviceId: serviceId),
child: ServiceMobileUploadScreen(
serviceId: serviceId,
serviceName: serviceName,
),
); );
}, },
), ),
@@ -162,8 +482,6 @@ class AppRouter {
} }
} }
/// Utility fondamentale per GoRouter: trasforma lo Stream del Cubit
/// in un Listenable che GoRouter può ascoltare per forzare i redirect.
class GoRouterRefreshStream extends ChangeNotifier { class GoRouterRefreshStream extends ChangeNotifier {
GoRouterRefreshStream(Stream<dynamic> stream) { GoRouterRefreshStream(Stream<dynamic> stream) {
notifyListeners(); notifyListeners();
@@ -171,9 +489,7 @@ class GoRouterRefreshStream extends ChangeNotifier {
(dynamic _) => notifyListeners(), (dynamic _) => notifyListeners(),
); );
} }
late final StreamSubscription<dynamic> _subscription; late final StreamSubscription<dynamic> _subscription;
@override @override
void dispose() { void dispose() {
_subscription.cancel(); _subscription.cancel();

View File

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

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,20 @@
import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
class AppMessage {
final String key;
final String? argument;
const AppMessage({required this.key, this.argument});
String translatedMessage(BuildContext context) {
switch (key) {
case 'authCubitCheckEmailToConfirmAccount':
return context.l10n.authCubitCheckEmailToConfirmAccount;
case 'authCubitResetPasswordEmailSentTo':
return context.l10n.authCubitResetPasswordEmailSentTo(argument!);
default:
return 'empty message';
}
}
}

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

@@ -1,3 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flux/l10n/app_localizations.dart';
extension MyStringExtensions on String? { extension MyStringExtensions on String? {
// Gestiamo anche il nullable per sicurezza // Gestiamo anche il nullable per sicurezza
String myFormat() { String myFormat() {
@@ -40,3 +43,7 @@ extension MyStringExtensions on String? {
.join('.'); // Ritorna tutto tranne l'ultima parte .join('.'); // Ritorna tutto tranne l'ultima parte
} }
} }
extension LocalizationExtension on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this)!;
}

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

@@ -20,6 +20,7 @@ class FluxTextField extends StatefulWidget {
final List<TextInputFormatter>? inputFormatters; final List<TextInputFormatter>? inputFormatters;
final TextCapitalization? textCapitalization; final TextCapitalization? textCapitalization;
final bool? autocorrect; final bool? autocorrect;
final bool? enabled;
const FluxTextField({ const FluxTextField({
super.key, // Usiamo super.key per Flutter moderno super.key, // Usiamo super.key per Flutter moderno
@@ -39,6 +40,7 @@ class FluxTextField extends StatefulWidget {
this.inputFormatters, this.inputFormatters,
this.textCapitalization, this.textCapitalization,
this.autocorrect, this.autocorrect,
this.enabled = true,
}); });
@override @override
@@ -115,6 +117,7 @@ class _FluxTextFieldState extends State<FluxTextField> {
inputFormatters: widget.inputFormatters, inputFormatters: widget.inputFormatters,
textCapitalization: widget.textCapitalization ?? TextCapitalization.none, textCapitalization: widget.textCapitalization ?? TextCapitalization.none,
enabled: widget.enabled,
); );
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/utils/functions.dart'; import 'package:flux/core/utils/functions.dart';
class ImageViewerWidget extends StatelessWidget { class ImageViewerWidget extends StatelessWidget {
@@ -36,8 +37,8 @@ class ImageViewerWidget extends StatelessWidget {
return const CircularProgressIndicator(); return const CircularProgressIndicator();
} }
if (snapshot.hasError) { if (snapshot.hasError) {
return const Text( return Text(
"Errore caricamento immagine (Permessi negati?)", context.l10n.imageViewerWidgetErrorOpening,
style: TextStyle(color: Colors.red), style: TextStyle(color: Colors.red),
); );
} }

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/utils/functions.dart'; import 'package:flux/core/utils/functions.dart';
import 'package:pdfx/pdfx.dart'; import 'package:pdfx/pdfx.dart';
import 'package:internet_file/internet_file.dart'; import 'package:internet_file/internet_file.dart';
@@ -74,13 +75,13 @@ class _PdfViewerWidgetState extends State<PdfViewerWidget> {
if (_errorMessage != null) { if (_errorMessage != null) {
return Scaffold( return Scaffold(
appBar: AppBar(leading: const CloseButton()), appBar: AppBar(leading: const CloseButton()),
body: Center(child: Text("Errore: $_errorMessage")), body: Center(child: Text(context.l10n.commonError(_errorMessage!))),
); );
} }
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("Anteprima PDF"), title: Text(context.l10n.pdfViewerAnteprimaPdf),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),

View File

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

View File

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

View File

@@ -0,0 +1,838 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/attachments/data/attachments_repository.dart';
import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart';
import 'package:flux/features/attachments/ui/quick_rename_dialog.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx
class _ExportItem {
final Uint8List bytes;
final String sourceName;
final bool isMultiPage;
final int pageIndex;
_ExportItem({
required this.bytes,
required this.sourceName,
required this.isMultiPage,
required this.pageIndex,
});
}
class SharedAttachmentsSection extends StatefulWidget {
final String? parentId;
final String titleForUpload;
final AttachmentParentType parentType;
final Future<String?> Function()? onGenerateIdForQr;
const SharedAttachmentsSection({
super.key,
this.parentId,
this.titleForUpload = 'Cliente_sconosciuto',
required this.parentType,
this.onGenerateIdForQr,
});
@override
State<SharedAttachmentsSection> createState() =>
_SharedAttachmentsSectionState();
}
class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
String? _exportDirectory;
@override
void initState() {
super.initState();
_loadExportDirectory();
}
// --- GESTIONE CARTELLA CITRIX (SOLO DESKTOP) ---
Future<void> _loadExportDirectory() async {
if (kIsWeb) return;
final prefs = await SharedPreferences.getInstance();
setState(() {
_exportDirectory = prefs.getString('citrix_export_path');
});
}
Future<void> _selectExportDirectory() async {
final String? selectedDirectory = await FilePicker.getDirectoryPath(
dialogTitle: 'Seleziona la cartella di esportazione',
);
if (selectedDirectory != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('citrix_export_path', selectedDirectory);
setState(() {
_exportDirectory = selectedDirectory;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cartella Export impostata: $selectedDirectory'),
),
);
}
}
}
// --- SELEZIONE FILE DAL PC/TELEFONO ---
Future<void> _pickFiles() async {
final result = await FilePicker.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'],
withData: true,
);
if (result != null && mounted) {
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
context.read<AttachmentsBloc>().add(AddAttachmentsEvent(result.files));
}
}
// --- APERTURA VIEWER ---
void _openFile(AttachmentModel file) {
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
final operationFilesBloc = context.read<AttachmentsBloc>();
Navigator.push(
context,
MaterialPageRoute(
builder: (viewerContext) => BlocProvider.value(
value: operationFilesBloc,
child: AttachmentViewerScreen(
attachment: file,
onRename: (newName) {
// Spara l'evento al BLoC e lui farà il resto!
operationFilesBloc.add(RenameAttachmentEvent(file, newName));
},
onDelete: () {
operationFilesBloc.add(DeleteSpecificAttachmentEvent(file));
},
),
),
),
);
}
Future<void> _exportMergedPdf(List<AttachmentModel> selectedFiles) async {
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
);
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
// 1. "FLATTEN" DI TUTTO (Stessa magia di prima)
List<Uint8List> allPagesAsImages = [];
final repository = GetIt.I.get<AttachmentsRepository>();
for (var file in selectedFiles) {
Uint8List? fileBytes;
if (file.localBytes != null) {
fileBytes = file.localBytes;
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
fileBytes = await repository.downloadAttachmentBytes(
storagePath: file.storagePath!,
bucket: Bucket.documents,
);
}
if (fileBytes == null) continue;
if (file.extension == 'pdf') {
final document = await px.PdfDocument.openData(fileBytes);
for (int i = 1; i <= document.pagesCount; i++) {
final page = await document.getPage(i);
final pageImage = await page.render(
width: page.width * 2,
height: page.height * 2,
format: px.PdfPageImageFormat.jpeg,
);
if (pageImage != null) {
allPagesAsImages.add(pageImage.bytes);
}
await page.close();
}
await document.close();
} else {
// È un'immagine
allPagesAsImages.add(fileBytes);
}
}
if (mounted) Navigator.pop(context); // Togliamo il loading
// Se per qualche motivo la lista è vuota, usciamo
if (allPagesAsImages.isEmpty) return;
// 2. LOGICA DEL NOME SUGGERITO
String suggestedName;
if (selectedFiles.length == 1) {
// Se c'è un solo file (es. ho selezionato 3 foto ma poi ho deselezionato le altre)
suggestedName = selectedFiles.first.name;
} else {
// Se sono più file uniti
suggestedName = '${widget.titleForUpload}_Unito';
}
if (!mounted) return;
// 3. DIALOG DI CONFERMA (Mostriamo la PRIMA pagina come anteprima per fargli capire cos'è)
final finalName = await showDialog<String>(
context: context,
builder: (_) => QuickRenameDialog(
suggestedName: suggestedName,
previewWidget: Image.memory(
allPagesAsImages.first,
fit: BoxFit.contain,
),
),
);
if (finalName == null || finalName.isEmpty) return; // Ha annullato
// 4. CREAZIONE DEL PDF UNICO (IL MERGE VERO E PROPRIO)
final pdf = pw.Document();
// Cicliamo su tutte le immagini estratte e creiamo una pagina per ognuna
for (var imageBytes in allPagesAsImages) {
final pdfImage = pw.MemoryImage(imageBytes);
pdf.addPage(
pw.Page(
margin: pw.EdgeInsets.zero,
build: (pw.Context context) {
return pw.Center(child: pw.Image(pdfImage));
},
),
);
}
final mergedPdfBytes = await pdf.save();
// 5. SALVATAGGIO SUL DISCO
if (kIsWeb) {
// Trigger download web
} else {
final fileToSave = File('$_exportDirectory/$finalName.pdf');
await fileToSave.writeAsBytes(mergedPdfBytes);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('PDF Multi-pagina creato e salvato con successo!'),
),
);
}
} catch (e) {
if (mounted) {
// Se il loading è ancora aperto, lo chiudiamo
if (Navigator.canPop(context)) Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Errore durante l\'unione: $e')));
}
}
}
Future<void> _exportSplitPdfs(List<AttachmentModel> selectedFiles) async {
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
);
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
// 1. "FLATTEN" INTELLIGENTE (Ora usiamo la nostra classe _ExportItem)
List<_ExportItem> itemsToExport = [];
final repository = GetIt.I.get<AttachmentsRepository>();
for (var file in selectedFiles) {
Uint8List? fileBytes;
if (file.localBytes != null) {
fileBytes = file.localBytes;
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
fileBytes = await repository.downloadAttachmentBytes(
storagePath: file.storagePath!,
bucket: Bucket.documents,
);
}
if (fileBytes == null) continue;
// Recuperiamo il nome che l'utente ha (magari) già impostato
final baseName = file.name;
if (file.extension == 'pdf') {
final document = await px.PdfDocument.openData(fileBytes);
final isMulti =
document.pagesCount > 1; // Controlliamo se è multipagina!
for (int i = 1; i <= document.pagesCount; i++) {
final page = await document.getPage(i);
final pageImage = await page.render(
width: page.width * 2,
height: page.height * 2,
format: px.PdfPageImageFormat.jpeg,
);
if (pageImage != null) {
// Salviamo l'immagine CON il suo contesto storico
itemsToExport.add(
_ExportItem(
bytes: pageImage.bytes,
sourceName: baseName,
isMultiPage: isMulti,
pageIndex: i,
),
);
}
await page.close();
}
await document.close();
} else {
// SE È UN'IMMAGINE, la salviamo come singola pagina
itemsToExport.add(
_ExportItem(
bytes: fileBytes,
sourceName: baseName,
isMultiPage: false,
pageIndex: 1,
),
);
}
}
if (mounted) Navigator.pop(context);
// 2. IL CICLO UX
for (var item in itemsToExport) {
if (!mounted) return;
// LA TUA MAGIA UX SUI NOMI:
// Se è singolo (foto o PDF da 1 pag) -> Usa il nome originale nudo e crudo!
// Se è multipagina -> Usa il nome originale + il numero di pagina
String suggestedName = item.sourceName;
if (item.isMultiPage) {
suggestedName = '${item.sourceName}_Pag_${item.pageIndex}';
}
final finalName = await showDialog<String>(
context: context,
builder: (_) => QuickRenameDialog(
suggestedName: suggestedName,
previewWidget: Image.memory(item.bytes, fit: BoxFit.contain),
),
);
if (finalName == null || finalName.isEmpty) continue;
// CREAZIONE DEL PDF SINGOLO
final pdf = pw.Document();
final pdfImage = pw.MemoryImage(item.bytes); // Usiamo item.bytes!
pdf.addPage(
pw.Page(
margin: pw.EdgeInsets.zero,
build: (pw.Context context) {
return pw.Center(child: pw.Image(pdfImage));
},
),
);
final singlePdfBytes = await pdf.save();
if (kIsWeb) {
// Trigger download web
} else {
final fileToSave = File('$_exportDirectory/$finalName.pdf');
await fileToSave.writeAsBytes(singlePdfBytes);
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Esportazione completata con successo!'),
),
);
}
} catch (e) {
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Errore: $e')));
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) {
final allFiles = state.allFiles;
final selectedFiles = state.selectedFiles;
final hasSelection = selectedFiles.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. SETTINGS CARTELLA (Solo visibile su Desktop)
if (!kIsWeb)
Card(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
elevation: 0,
margin: const EdgeInsets.only(bottom: 16),
child: ListTile(
leading: Icon(
Icons.folder_special,
color: theme.colorScheme.primary,
),
title: const Text(
'Cartella Export PDF',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
_exportDirectory ??
'Nessuna cartella selezionata. Clicca per impostare.',
style: TextStyle(
color: _exportDirectory == null
? theme.colorScheme.error
: null,
),
),
trailing: const Icon(Icons.settings),
onTap: _selectExportDirectory,
),
),
// 2. ACTION BAR DINAMICA
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
// Bottone di Aggiunta
ElevatedButton.icon(
icon: const Icon(Icons.add_photo_alternate),
label: const Text('Aggiungi File'),
onPressed: state.status == AttachmentsStatus.uploading
? null
: _pickFiles,
/* : () {
final bloc = context.read<AttachmentsBloc>();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => BlocProvider.value(
value: bloc,
child: SharedMobileUploadScreen(
title: widget.titleForUpload,
),
),
),
);
}, */
),
const SizedBox(width: 8),
Tooltip(
message: 'Carica foto con lo smartphone',
child: IconButton(
icon: const Icon(Icons.qr_code_scanner),
color: theme.colorScheme.primary, // Sempre colorato!
onPressed: () async {
String? targetId = state.parentId;
// SE L'ID NON C'È, CHIAMIAMO IL SALVATAGGIO IN BACKGROUND!
if (targetId == null) {
if (widget.onGenerateIdForQr != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Salvataggio rapido scheda in corso... ⏳',
),
duration: Duration(seconds: 1),
),
);
// Aspettiamo che il TicketFormCubit faccia il suo lavoro
targetId = await widget.onGenerateIdForQr!();
}
// Se fallisce (es. validazione form non passata), ci fermiamo
if (targetId == null) return;
}
// GENERAZIONE DEL DEEP LINK AGNOSTICO
final companyId = GetIt.I
.get<SessionCubit>()
.state
.company!
.id!;
final deepLink =
'https://flux.catelli.it/upload/${state.parentType.name}/$targetId?companyId=$companyId';
if (context.mounted) {
final attachmentBloc = context.read<AttachmentsBloc>();
showDialog(
context: context,
builder: (_) => BlocProvider.value(
value: attachmentBloc,
child: QrUploadDialog(
deepLinkUrl: deepLink,
title: 'Carica File: ${widget.titleForUpload}',
),
),
);
}
},
),
),
const SizedBox(width: 12),
// NUOVO: SELEZIONA / DESELEZIONA TUTTO
if (allFiles.isNotEmpty) ...[
TextButton.icon(
icon: Icon(
selectedFiles.length == allFiles.length
? Icons.deselect
: Icons.select_all,
),
label: Text(
selectedFiles.length == allFiles.length
? 'Deseleziona Tutto'
: 'Seleziona Tutto',
),
onPressed: () {
if (selectedFiles.length == allFiles.length) {
context.read<AttachmentsBloc>().add(
ClearAttachmentSelectionEvent(),
);
} else {
context.read<AttachmentsBloc>().add(
SelectAllAttachmentsEvent(),
);
}
},
),
],
const SizedBox(width: 12),
// Loader di upload
if (state.status == AttachmentsStatus.uploading)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
// Azioni visibili SOLO se c'è una selezione!
if (hasSelection) ...[
// Bottone Elimina
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Elimina selezionati',
onPressed: () {
context.read<AttachmentsBloc>().add(
DeleteAttachmentsEvent(),
);
},
),
// Bottone Associa a Cliente
if (widget.parentId != null && widget.parentId != '')
IconButton(
icon: const Icon(Icons.person_add, color: Colors.blue),
tooltip: 'Copia nei documenti del Cliente',
onPressed: () {
context.read<AttachmentsBloc>().add(
LinkAttachmentsToEntityEvent(
targetId: widget.parentId!,
targetType: AttachmentParentType.customer,
),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('File copiati nella scheda cliente!'),
),
);
},
),
// IL NUOVO BOTTONE ESPORTA CON MENU A TENDINA
PopupMenuButton<String>(
tooltip: 'Opzioni di esportazione',
position: PopupMenuPosition
.under, // Opzionale: fa aprire il menu sotto al bottone
onSelected: (value) {
if (value == 'merge') {
_exportMergedPdf(selectedFiles);
} else if (value == 'split') {
_exportSplitPdfs(selectedFiles);
}
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
const PopupMenuItem<String>(
value: 'merge',
child: ListTile(
leading: Icon(
Icons.merge_type,
color: Colors.blue,
),
title: Text('Unisci in un singolo PDF'),
),
),
const PopupMenuItem<String>(
value: 'split',
child: ListTile(
leading: Icon(
Icons.splitscreen,
color: Colors.orange,
),
title: Text(
'Dividi: un PDF per ogni pagina/foto',
),
),
),
],
// IL FIX È QUI SOTTO: AbsorbPointer + onPressed vuoto
child: AbsorbPointer(
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
),
icon: const Icon(Icons.picture_as_pdf),
label: Text('Esporta (${selectedFiles.length})'),
onPressed: () {}, // Manteniamo vivo il colore!
),
),
),
],
],
),
const SizedBox(height: 16),
// 3. GRIGLIA DEI FILE
if (allFiles.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
border: Border.all(
color: theme.dividerColor,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(8),
),
child: const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.upload_file, size: 48, color: Colors.grey),
SizedBox(height: 8),
Text(
'Nessun file allegato. Usa il pulsante per aggiungere documenti o foto.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
],
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.8,
),
itemCount: allFiles.length,
itemBuilder: (context, index) {
final file = allFiles[index];
final isPdf = file.extension == 'pdf';
final isSelected = selectedFiles.contains(file);
final isLocal =
file.localBytes !=
null; // Per capire se è un file in bozza
return Stack(
children: [
// CARD DEL FILE
InkWell(
onTap: () => _openFile(file),
onLongPress: () {
// Selezione rapida con long press!
context.read<AttachmentsBloc>().add(
ToggleAttachmentSelectionEvent(file),
);
},
borderRadius: BorderRadius.circular(8),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.dividerColor,
width: isSelected ? 3 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Anteprima
Expanded(
child: Container(
decoration: BoxDecoration(
color: theme
.colorScheme
.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(8),
),
),
child: isPdf
? const Icon(
Icons.picture_as_pdf,
size: 48,
color: Colors.red,
)
: isLocal
? ClipRRect(
borderRadius:
const BorderRadius.vertical(
top: Radius.circular(8),
),
child: Image.memory(
file.localBytes!,
fit: BoxFit.cover,
),
)
: const Icon(
Icons.image,
size: 48,
color: Colors.blue,
), // Da remoto metterai il tuo NetworkImage se vuoi
),
),
// Nome File
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
file.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.center,
),
),
],
),
),
),
// CHECKBOX DI SELEZIONE
Positioned(
top: 4,
right: 4,
child: InkWell(
onTap: () {
context.read<AttachmentsBloc>().add(
ToggleAttachmentSelectionEvent(file),
);
},
child: Container(
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: Colors.white.withValues(alpha: 0.8),
shape: BoxShape.circle,
border: Border.all(
color: theme.colorScheme.primary,
),
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Icon(
isSelected ? Icons.check : Icons.circle,
size: 16,
color: isSelected
? Colors.white
: Colors.transparent,
),
),
),
),
),
// BADGE "IN ATTESA" (Se è locale ma la pratica è salvata)
if (isLocal)
Positioned(
top: 4,
left: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Bozza',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
},
),
],
);
},
);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:get_it/get_it.dart';
class StaffSection extends StatelessWidget {
final String? label;
final String? staffId;
final String? staffName;
final ValueChanged<StaffMemberModel> onStaffSelected;
const StaffSection({
super.key,
required this.onStaffSelected,
this.label,
this.staffId,
this.staffName,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Se staffId è nullo, proviamo a preselezionare l'utente loggato
final selectedStaffId =
staffId ?? GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
label ??
'Operatore', // <-- FIX: Ora usa l'etichetta passata dal form!
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) {
// FIX: Aggiunto un controllo se sta caricando
if (state.status == StaffStatus.loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final staffMembers = state.storeStaff;
// FIX: Feedback visivo se la lista è vuota
if (staffMembers.isEmpty) {
return const Text(
'Nessun operatore caricato. Controlla il Cubit!',
style: TextStyle(color: Colors.red),
);
}
final currentLoggedStaffMember = GetIt.I
.get<SessionCubit>()
.state
.currentStaffMember;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: staffMembers.map((staff) {
final isSelected = staff.id == selectedStaffId;
return GestureDetector(
onTap: () {
onStaffSelected(staff);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(right: 12.0),
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 10.0,
),
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.dividerColor,
width: 1.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: theme.colorScheme.primary.withValues(
alpha: 0.3,
),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 12,
backgroundColor: isSelected
? Colors.white
: theme.colorScheme.primaryContainer,
child: Text(
staff.name.substring(0, 1).toUpperCase(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 8),
Text(
staff == currentLoggedStaffMember
? 'Tu (${staff.name})'
: staff.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.w500,
color: isSelected
? Colors.white
: theme.colorScheme.onSurface,
),
),
],
),
),
);
}).toList(),
),
);
},
),
],
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,132 @@
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
class AttachmentModel extends Equatable {
final String? id;
final DateTime? createdAt;
final String? customerId;
final String? operationId;
final String? ticketId;
final String? shippingDocumentId;
final String? noteId;
final String name;
final String extension;
final String? storagePath;
final int fileSize;
final Uint8List? localBytes;
final String companyId;
const AttachmentModel({
this.id,
this.createdAt,
this.customerId,
this.operationId,
this.ticketId,
this.shippingDocumentId,
this.noteId,
required this.name,
required this.extension,
this.storagePath,
required this.fileSize,
this.localBytes,
required this.companyId,
});
@override
List<Object?> get props => [
id,
createdAt,
customerId,
operationId,
ticketId,
shippingDocumentId,
noteId,
name,
extension,
storagePath,
fileSize,
localBytes,
companyId,
];
bool get isLocal => localBytes != null;
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
String get sizeFormatted {
if (fileSize <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"];
var i = (fileSize.toString().length - 1) ~/ 3;
if (i >= suffixes.length) i = suffixes.length - 1;
double num = fileSize / (1 << (i * 10));
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
}
AttachmentModel copyWith({
String? id,
DateTime? createdAt,
String? customerId,
String? operationId,
String? ticketId,
String? shippingDocumentId,
String? noteId,
String? name,
String? extension,
String? storagePath,
int? fileSize,
Uint8List? localBytes,
String? companyId,
}) => AttachmentModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
customerId: customerId ?? this.customerId,
operationId: operationId ?? this.operationId,
ticketId: ticketId ?? this.ticketId,
shippingDocumentId: shippingDocumentId ?? this.shippingDocumentId,
noteId: noteId ?? this.noteId,
name: name ?? this.name,
extension: extension ?? this.extension,
storagePath: storagePath ?? this.storagePath,
fileSize: fileSize ?? this.fileSize,
localBytes: localBytes ?? this.localBytes,
companyId: companyId ?? this.companyId,
);
factory AttachmentModel.fromMap(Map<String, dynamic> map) {
return AttachmentModel(
id: map['id'] as String,
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
customerId: map['customer_id'] as String?,
operationId: map['operation_id'] as String?,
ticketId: map['ticket_id'] as String?,
shippingDocumentId: map['shipping_document_id'] as String?,
noteId: map['note_id'] as String?,
name: map['name'] as String,
extension: map['extension'] as String,
storagePath: map['storage_path'] as String?,
fileSize: map['file_size'] is int
? map['file_size']
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
companyId: map['company_id'] as String,
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'name': name,
'extension': extension,
'storage_path': storagePath,
'customer_id': customerId,
'operation_id': operationId,
'ticket_id': ticketId,
'shipping_document_id': shippingDocumentId,
'note_id': noteId,
'file_size': fileSize,
'company_id': companyId,
};
}
}

View File

@@ -0,0 +1,220 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flux/core/utils/functions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:pdfx/pdfx.dart';
import 'package:internet_file/internet_file.dart';
class AttachmentViewerScreen extends StatefulWidget {
final AttachmentModel attachment;
final Function(String newName)? onRename;
final VoidCallback? onDelete;
const AttachmentViewerScreen({
super.key,
required this.attachment,
this.onRename,
this.onDelete,
});
@override
State<AttachmentViewerScreen> createState() => _AttachmentViewerScreenState();
}
class _AttachmentViewerScreenState extends State<AttachmentViewerScreen> {
PdfControllerPinch? _pdfController;
bool _isLoading = true;
String? _errorMessage;
Uint8List? _fileBytes;
late String _fileName;
bool get isPdf => widget.attachment.extension.toLowerCase() == 'pdf';
@override
void initState() {
super.initState();
_fileName = widget.attachment.name;
_loadFile();
}
Future<void> _loadFile() async {
try {
// 1. Capiamo da dove prendere i dati
if (widget.attachment.localBytes != null) {
_fileBytes = widget.attachment.localBytes;
} else if (widget.attachment.storagePath != null &&
widget.attachment.storagePath!.isNotEmpty) {
final signedUrl = await getSignedUrl(widget.attachment.storagePath!);
_fileBytes = await InternetFile.get(signedUrl);
} else {
throw Exception("Nessun documento trovato o byte mancanti.");
}
// 2. Se è PDF, inizializziamo il controller
if (isPdf && _fileBytes != null) {
_pdfController = PdfControllerPinch(
document: PdfDocument.openData(_fileBytes!),
);
}
if (mounted) setState(() => _isLoading = false);
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
}
}
}
@override
void dispose() {
_pdfController?.dispose();
super.dispose();
}
void _showRenameDialog() {
final ctrl = TextEditingController(text: _fileName);
ctrl.selection = TextSelection(
baseOffset: 0,
extentOffset: ctrl.text.length,
);
final focusNode = FocusNode();
showDialog(
context: context,
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => focusNode.requestFocus(),
);
return AlertDialog(
title: const Text('Rinomina File'),
content: TextField(
controller: ctrl,
focusNode: focusNode,
decoration: InputDecoration(
labelText: 'Nuovo nome',
suffixText: '.${widget.attachment.extension}',
),
onSubmitted: (val) {
Navigator.pop(context);
if (val.trim().isNotEmpty && widget.onRename != null) {
setState(() {
_fileName = val.trim();
});
widget.onRename!(val.trim());
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annulla'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
if (ctrl.text.trim().isNotEmpty && widget.onRename != null) {
setState(() {
_fileName = ctrl.text.trim();
});
widget.onRename!(ctrl.text.trim());
}
},
child: const Text('Salva'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black87, // Sfondo scuro per i viewer è il top
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
actions: [
if (widget.onRename != null)
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Rinomina',
onPressed: _showRenameDialog,
),
if (widget.onDelete != null)
IconButton(
icon: const Icon(Icons.delete, color: Colors.redAccent),
tooltip: 'Elimina',
onPressed: () {
// Chiediamo conferma
showDialog(
context: context,
builder: (c) => AlertDialog(
title: const Text('Eliminare file?'),
content: const Text(
'Sei sicuro di voler eliminare questo allegato?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('Annulla'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
onPressed: () {
Navigator.pop(c); // Chiude dialog
widget.onDelete!(); // Lancia eliminazione
Navigator.pop(context); // Chiude il viewer
},
child: const Text('Elimina'),
),
],
),
);
},
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_errorMessage != null) {
return Center(
child: Text(
'Errore: $_errorMessage',
style: const TextStyle(color: Colors.redAccent),
),
);
}
if (_fileBytes == null) {
return const Center(
child: Text(
'File non disponibile',
style: TextStyle(color: Colors.white),
),
);
}
if (isPdf && _pdfController != null) {
return PdfViewPinch(controller: _pdfController!);
} else {
return InteractiveViewer(
maxScale: 5.0,
child: Center(child: Image.memory(_fileBytes!)),
);
}
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
class QuickRenameDialog extends StatefulWidget {
final String suggestedName;
final Widget previewWidget; // Può essere Image.memory o un'icona PDF
const QuickRenameDialog({
super.key,
required this.suggestedName,
required this.previewWidget,
});
@override
State<QuickRenameDialog> createState() => _QuickRenameDialogState();
}
class _QuickRenameDialogState extends State<QuickRenameDialog> {
late TextEditingController _nameCtrl;
final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.suggestedName);
// MAGIA UX: Selezioniamo tutto il testo di default appena si apre!
_nameCtrl.selection = TextSelection(
baseOffset: 0,
extentOffset: widget.suggestedName.length,
);
// Richiediamo il focus appena il widget è costruito
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_nameCtrl.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Rinomina per Export'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Anteprima del documento (limitiamo l'altezza)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(border: Border.all(color: Colors.grey)),
child: widget.previewWidget,
),
const SizedBox(height: 16),
TextField(
controller: _nameCtrl,
focusNode: _focusNode,
decoration: const InputDecoration(
labelText: 'Nome del file',
suffixText: '.pdf', // Facciamo capire che sarà un PDF
border: OutlineInputBorder(),
),
// MAGIA UX 2: Se preme invio sulla tastiera, salva e chiude!
onSubmitted: (value) => Navigator.of(context).pop(value),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(), // Ritorna null
child: const Text('Salta'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(_nameCtrl.text),
child: const Text('Esporta (Invio)'),
),
],
);
}
}

View File

@@ -1,6 +1,8 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/core/utils/app_message.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
part 'auth_state.dart'; part 'auth_state.dart';
@@ -40,7 +42,9 @@ class AuthCubit extends Cubit<AuthState> {
emit( emit(
state.copyWith( state.copyWith(
status: AuthStatus.initial, status: AuthStatus.initial,
infoMessage: "Controlla la tua email per confermare l'account!", infoMessage: AppMessage(
key: 'authCubitCheckEmailToConfirmAccount',
),
), ),
); );
} else { } else {
@@ -64,6 +68,31 @@ class AuthCubit extends Cubit<AuthState> {
} }
} }
Future<void> requestPasswordReset(String email) async {
if (email.isEmpty) {
emit(
state.copyWith(
status: AuthStatus.failure,
errorMessage: 'Devi inserire l\'indirizzo email',
),
);
return;
}
await _supabase.auth.resetPasswordForEmail(
email,
redirectTo: resetPasswordUrl,
);
emit(
state.copyWith(
status: AuthStatus.pwResetSent,
infoMessage: AppMessage(
key: 'authCubitResetPasswordEmailSentTo',
argument: email,
),
),
);
}
Future<void> requestLogout() async { Future<void> requestLogout() async {
await _supabase.auth.signOut(); await _supabase.auth.signOut();
emit(state.copyWith(status: AuthStatus.initial)); emit(state.copyWith(status: AuthStatus.initial));

View File

@@ -1,12 +1,12 @@
part of 'auth_cubit.dart'; part of 'auth_cubit.dart';
enum AuthStatus { initial, loading, failure } enum AuthStatus { initial, pwResetSent, loading, failure }
class AuthState extends Equatable { class AuthState extends Equatable {
final AuthStatus status; final AuthStatus status;
final bool isLoginMode; final bool isLoginMode;
final String? errorMessage; final String? errorMessage;
final String? infoMessage; final AppMessage? infoMessage;
const AuthState({ const AuthState({
this.status = AuthStatus.initial, this.status = AuthStatus.initial,
@@ -19,7 +19,7 @@ class AuthState extends Equatable {
AuthStatus? status, AuthStatus? status,
bool? isLoginMode, bool? isLoginMode,
String? errorMessage, String? errorMessage,
String? infoMessage, AppMessage? infoMessage,
}) { }) {
return AuthState( return AuthState(
status: status ?? this.status, status: status ?? this.status,

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/theme/theme.dart'; import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/flux_logo.dart'; import 'package:flux/core/widgets/flux_logo.dart';
import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/auth/bloc/auth_cubit.dart'; import 'package:flux/features/auth/bloc/auth_cubit.dart';
@@ -55,7 +56,7 @@ class _AuthScreenState extends State<AuthScreen> {
if (state.infoMessage != null) { if (state.infoMessage != null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(state.infoMessage!), content: Text(state.infoMessage!.translatedMessage(context)),
backgroundColor: Colors.blueAccent, // O context.accent backgroundColor: Colors.blueAccent, // O context.accent
), ),
); );
@@ -77,7 +78,9 @@ class _AuthScreenState extends State<AuthScreen> {
// --- TITOLO DINAMICO --- // --- TITOLO DINAMICO ---
Text( Text(
state.isLoginMode ? 'BENTORNATO' : 'CREA ACCOUNT', state.isLoginMode
? context.l10n.authScreenWelcomeBack
: context.l10n.authScreenCreateAccount,
style: TextStyle( style: TextStyle(
color: context.primaryText, color: context.primaryText,
fontSize: 24, fontSize: 24,
@@ -88,8 +91,10 @@ class _AuthScreenState extends State<AuthScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
state.isLoginMode state.isLoginMode
? 'Accedi per gestire il tuo business' ? context.l10n.authScreenLoginToManageYourBusiness
: 'Inizia oggi a digitalizzare il tuo negozio', : context
.l10n
.authScreenStartTodayToDigitalizeYourStore,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: context.secondaryText), style: TextStyle(color: context.secondaryText),
), ),
@@ -97,7 +102,7 @@ class _AuthScreenState extends State<AuthScreen> {
// --- CAMPI INPUT --- // --- CAMPI INPUT ---
FluxTextField( FluxTextField(
label: 'Email Aziendale', label: context.l10n.authScreenBusinessEmail,
icon: Icons.email_outlined, icon: Icons.email_outlined,
controller: _emailController, controller: _emailController,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
@@ -130,7 +135,9 @@ class _AuthScreenState extends State<AuthScreen> {
), ),
) )
: Text( : Text(
state.isLoginMode ? 'ACCEDI' : 'REGISTRATI', state.isLoginMode
? context.l10n.authScreenLogin
: context.l10n.authScreenSignUp,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -147,12 +154,15 @@ class _AuthScreenState extends State<AuthScreen> {
child: RichText( child: RichText(
text: TextSpan( text: TextSpan(
text: state.isLoginMode text: state.isLoginMode
? "Non hai un account? " ? context.l10n.authScreenDontHaveAccount
: "Hai già un account? ", : context.l10n.authScreenAlreadyHaveAccount,
style: TextStyle(color: context.secondaryText), style: TextStyle(color: context.secondaryText),
children: [ children: [
TextSpan( TextSpan(
text: state.isLoginMode ? "Registrati" : "Accedi", text: state.isLoginMode
? context.l10n.authScreenSignUp
: context.l10n.authScreenLogin,
style: TextStyle( style: TextStyle(
color: context.accent, color: context.accent,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -162,6 +172,21 @@ class _AuthScreenState extends State<AuthScreen> {
), ),
), ),
), ),
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,33 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/company/data/company_repository.dart';
import 'package:flux/features/company/models/company_model.dart';
import 'package:get_it/get_it.dart';
part 'company_events.dart';
part 'company_state.dart';
class CompanyBloc extends Bloc<CompanyEvent, CompanyState> {
final CompanyRepository _repository = GetIt.I<CompanyRepository>();
CompanyBloc() : super(const CompanyState(status: CompanyStatus.initial)) {
on<CreateCompanyRequested>((event, emit) async {
emit(const CompanyState(status: CompanyStatus.loading));
try {
final createdCompany = await _repository.createCompany(event.company);
emit(
state.copyWith(
status: CompanyStatus.success,
company: createdCompany,
),
);
} catch (e) {
emit(
state.copyWith(
status: CompanyStatus.failure,
errorMessage: e.toString(),
),
);
}
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -35,6 +35,21 @@ enum SubscriptionStatus {
} }
} }
enum LabelFormat {
none,
small_62x29,
medium_54x101,
large_102x152,
custom;
static LabelFormat fromString(String? value) {
return LabelFormat.values.firstWhere(
(e) => e.name == value,
orElse: () => LabelFormat.none,
);
}
}
// =================================================================== // ===================================================================
// IL MODELLO ESATTO // IL MODELLO ESATTO
// =================================================================== // ===================================================================
@@ -45,16 +60,22 @@ class CompanyModel extends Equatable {
final String userId; // Nel DB è user_id (chiave esterna su auth.users) final String userId; // Nel DB è user_id (chiave esterna su auth.users)
// Dati Anagrafici e Fatturazione // Dati Anagrafici e Fatturazione
final String ragioneSociale; final String name;
final String indirizzo; final String address;
final String cap; final String zipCode;
final String citta; final String city;
final String provincia; final String province;
final String partitaIva; final String vatId;
final String codiceFiscale; final String fiscalCode;
final String codiceUnivoco; final String sdi;
final String companyLogo; final String? phone;
final String? email;
final String? logoUrl;
final String? ticketDisclaimer;
final LabelFormat labelFormat;
final double? labelWidth;
final double? labelHeight;
final bool isLabelVertical;
// Stato Pagamenti (Ibride: manuale + Stripe) // Stato Pagamenti (Ibride: manuale + Stripe)
final bool isPaid; final bool isPaid;
final DateTime? paymentExpiration; final DateTime? paymentExpiration;
@@ -70,15 +91,22 @@ class CompanyModel extends Equatable {
this.id, this.id,
this.createdAt, this.createdAt,
required this.userId, required this.userId,
required this.ragioneSociale, required this.name,
required this.indirizzo, required this.address,
required this.cap, required this.zipCode,
required this.citta, required this.city,
required this.provincia, required this.province,
required this.partitaIva, required this.vatId,
required this.codiceFiscale, required this.fiscalCode,
required this.codiceUnivoco, required this.sdi,
this.companyLogo = '', this.phone,
this.email,
this.logoUrl,
this.ticketDisclaimer,
this.labelFormat = LabelFormat.none,
this.labelWidth,
this.labelHeight,
this.isLabelVertical = false,
this.isPaid = false, this.isPaid = false,
this.paymentExpiration, this.paymentExpiration,
this.subscriptionTier = SubscriptionTier.free, this.subscriptionTier = SubscriptionTier.free,
@@ -92,15 +120,22 @@ class CompanyModel extends Equatable {
String? id, String? id,
DateTime? createdAt, DateTime? createdAt,
String? userId, String? userId,
String? ragioneSociale, String? name,
String? indirizzo, String? address,
String? cap, String? zipCode,
String? citta, String? city,
String? provincia, String? province,
String? partitaIva, String? vatId,
String? codiceFiscale, String? fiscalCode,
String? codiceUnivoco, String? sdi,
String? companyLogo, String? logoUrl,
String? ticketDisclaimer,
LabelFormat? labelFormat,
double? labelWidth,
double? labelHeight,
bool? isLabelVertical,
String? phone,
String? email,
bool? isPaid, bool? isPaid,
DateTime? paymentExpiration, DateTime? paymentExpiration,
SubscriptionTier? subscriptionTier, SubscriptionTier? subscriptionTier,
@@ -113,15 +148,22 @@ class CompanyModel extends Equatable {
id: id ?? this.id, id: id ?? this.id,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
userId: userId ?? this.userId, userId: userId ?? this.userId,
ragioneSociale: ragioneSociale ?? this.ragioneSociale, name: name ?? this.name,
indirizzo: indirizzo ?? this.indirizzo, address: address ?? this.address,
cap: cap ?? this.cap, zipCode: zipCode ?? this.zipCode,
citta: citta ?? this.citta, city: city ?? this.city,
provincia: provincia ?? this.provincia, province: province ?? this.province,
partitaIva: partitaIva ?? this.partitaIva, vatId: vatId ?? this.vatId,
codiceFiscale: codiceFiscale ?? this.codiceFiscale, fiscalCode: fiscalCode ?? this.fiscalCode,
codiceUnivoco: codiceUnivoco ?? this.codiceUnivoco, sdi: sdi ?? this.sdi,
companyLogo: companyLogo ?? this.companyLogo, logoUrl: logoUrl ?? this.logoUrl,
phone: phone ?? this.phone,
email: email ?? this.email,
ticketDisclaimer: ticketDisclaimer ?? this.ticketDisclaimer,
labelFormat: labelFormat ?? this.labelFormat,
labelWidth: labelWidth ?? this.labelWidth,
labelHeight: labelHeight ?? this.labelHeight,
isLabelVertical: isLabelVertical ?? this.isLabelVertical,
isPaid: isPaid ?? this.isPaid, isPaid: isPaid ?? this.isPaid,
paymentExpiration: paymentExpiration ?? this.paymentExpiration, paymentExpiration: paymentExpiration ?? this.paymentExpiration,
subscriptionTier: subscriptionTier ?? this.subscriptionTier, subscriptionTier: subscriptionTier ?? this.subscriptionTier,
@@ -137,14 +179,14 @@ class CompanyModel extends Equatable {
id: null, id: null,
createdAt: null, createdAt: null,
userId: '', userId: '',
ragioneSociale: '', name: '',
indirizzo: '', address: '',
cap: '', zipCode: '',
citta: '', city: '',
provincia: '', province: '',
partitaIva: '', vatId: '',
codiceFiscale: '', fiscalCode: '',
codiceUnivoco: '', sdi: '',
); );
} }
@@ -155,15 +197,26 @@ class CompanyModel extends Equatable {
? DateTime.tryParse(map['created_at']) ? DateTime.tryParse(map['created_at'])
: null, : null,
userId: map['user_id'] ?? '', userId: map['user_id'] ?? '',
ragioneSociale: map['ragione_sociale'] ?? '', name: map['name'] ?? '',
indirizzo: map['indirizzo'] ?? '', address: map['address'] ?? '',
cap: map['cap'] ?? '', zipCode: map['zip_code'] ?? '',
citta: map['citta'] ?? '', city: map['city'] ?? '',
provincia: map['provincia'] ?? '', province: map['province'] ?? '',
partitaIva: map['partita_iva'] ?? '', vatId: map['vat_id'] ?? '',
codiceFiscale: map['codice_fiscale'] ?? '', fiscalCode: map['fiscal_code'] ?? '',
codiceUnivoco: map['codice_univoco'] ?? '', sdi: map['sdi'] ?? '',
companyLogo: map['company_logo'] ?? '', logoUrl: map['logo_url'],
phone: map['phone'] ?? '',
email: map['email'] ?? '',
ticketDisclaimer: map['ticket_disclaimer'],
labelFormat: LabelFormat.fromString(map['label_format']),
labelWidth: map['label_width'] != null
? (map['label_width'] as num).toDouble()
: null,
labelHeight: map['label_height'] != null
? (map['label_height'] as num).toDouble()
: null,
isLabelVertical: map['is_label_vertical'] ?? false,
isPaid: map['is_paid'] ?? false, isPaid: map['is_paid'] ?? false,
paymentExpiration: map['payment_expiration'] != null paymentExpiration: map['payment_expiration'] != null
? DateTime.tryParse(map['payment_expiration']) ? DateTime.tryParse(map['payment_expiration'])
@@ -185,15 +238,22 @@ class CompanyModel extends Equatable {
if (id != null) 'id': id, if (id != null) 'id': id,
// created_at è gestito dal DB di default, di solito non si passa nell'insert // created_at è gestito dal DB di default, di solito non si passa nell'insert
'user_id': userId, 'user_id': userId,
'ragione_sociale': ragioneSociale, 'name': name,
'indirizzo': indirizzo, 'address': address,
'cap': cap, 'zip_code': zipCode,
'citta': citta, 'city': city,
'provincia': provincia, 'province': province,
'partita_iva': partitaIva, 'vat_id': vatId,
'codice_fiscale': codiceFiscale, 'fiscal_code': fiscalCode,
'codice_univoco': codiceUnivoco, 'sdi': sdi,
'company_logo': companyLogo, 'logo_url': logoUrl,
'phone': phone,
'email': email,
'ticket_disclaimer': ticketDisclaimer,
'label_format': labelFormat.name,
'label_width': labelWidth,
'label_height': labelHeight,
'is_label_vertical': isLabelVertical,
'is_paid': isPaid, 'is_paid': isPaid,
if (paymentExpiration != null) if (paymentExpiration != null)
'payment_expiration': paymentExpiration!.toIso8601String(), 'payment_expiration': paymentExpiration!.toIso8601String(),
@@ -213,15 +273,22 @@ class CompanyModel extends Equatable {
id, id,
createdAt, createdAt,
userId, userId,
ragioneSociale, name,
indirizzo, address,
cap, zipCode,
citta, city,
provincia, province,
partitaIva, vatId,
codiceFiscale, fiscalCode,
codiceUnivoco, sdi,
companyLogo, logoUrl,
phone,
email,
ticketDisclaimer,
labelFormat,
labelWidth,
labelHeight,
isLabelVertical,
isPaid, isPaid,
paymentExpiration, paymentExpiration,
subscriptionTier, subscriptionTier,
@@ -263,7 +330,7 @@ extension CompanyLimits on CompanyModel {
} }
} }
int get maxServicesPerMonth { int get maxOperationsPerMonth {
switch (subscriptionTier) { switch (subscriptionTier) {
case SubscriptionTier.free: case SubscriptionTier.free:
return 50; return 50;

View File

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

View File

@@ -1,322 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/company/bloc/company_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/company/models/company_model.dart';
class CreateCompanyScreen extends StatefulWidget {
const CreateCompanyScreen({super.key});
@override
State<CreateCompanyScreen> createState() => _CreateCompanyScreenState();
}
// lib/ui/setup/create_company_screen.dart
class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
final _formKey = GlobalKey<FormState>();
// Controller per i campi obbligatori
final _ragioneSocialeController = TextEditingController();
final _indirizzoController = TextEditingController();
final _capController = TextEditingController();
final _cittaController = TextEditingController();
final _provinciaController = TextEditingController();
final _pIvaController = TextEditingController();
final _cfController = TextEditingController();
final _univocoController = TextEditingController();
@override
void dispose() {
// Ricordati sempre di chiuderli!
_ragioneSocialeController.dispose();
_indirizzoController.dispose();
_capController.dispose();
_cittaController.dispose();
_provinciaController.dispose();
_pIvaController.dispose();
_cfController.dispose();
_univocoController.dispose();
super.dispose();
}
void _onSave() {
if (_formKey.currentState!.validate()) {
// Recuperiamo l'ID utente attuale da Supabase o dal SessionBloc
final userId = context.read<SessionCubit>().state.user!.id;
final company = CompanyModel(
userId: userId,
ragioneSociale: _ragioneSocialeController.text.trim(),
indirizzo: _indirizzoController.text.trim(),
cap: _capController.text.trim(),
citta: _cittaController.text.trim(),
provincia: _provinciaController.text.trim(),
partitaIva: _pIvaController.text.trim(),
codiceFiscale: _cfController.text.trim(),
codiceUnivoco: _univocoController.text.trim().toUpperCase(),
// Gli altri campi hanno i default nel modello
);
// Spariamo l'evento al Bloc
context.read<CompanyBloc>().add(CreateCompanyRequested(company: company));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Configurazione Azienda'),
actions: [
IconButton(
icon: const Icon(Icons.logout_rounded),
onPressed: () {
// Qui chiami il tuo Bloc dell'autenticazione per fare logout
// Esempio se hai un AuthBloc o SessionBloc:
//context.read<AuthBloc>().add(LogoutRequested());
// Se vuoi solo tornare brutalmente alla login per testare il logo:
// Navigator.of(context).pushReplacementNamed('/login');
},
),
],
),
body: BlocConsumer<CompanyBloc, CompanyState>(
listener: (context, state) {
if (state.status == CompanyStatus.success && state.company != null) {
// 1. Aggiorniamo la singleton con i dati reali (ID incluso)
//GetIt.I.get<AppSettings>().setCurrentCompany(state.company);
// 2. Notifichiamo il SessionBloc per cambiare pagina
//context.read<SessionCubit>().add(AppStarted());
}
if (state.status == CompanyStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
state.errorMessage ?? 'Errore durante il salvataggio',
),
backgroundColor: Colors.redAccent,
),
);
}
},
builder: (context, state) {
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(context),
const SizedBox(height: 32),
// --- SEZIONE 1: IDENTITÀ FISCALE ---
_SectionTitle(title: 'DATI FISCALI'),
const SizedBox(height: 16),
FluxTextField(
label: 'Ragione Sociale',
icon: Icons.business,
controller: _ragioneSocialeController,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FluxTextField(
label: 'Partita IVA',
icon: Icons.numbers,
controller: _pIvaController,
),
),
const SizedBox(width: 12),
Expanded(
child: FluxTextField(
label: 'Codice Fiscale',
icon: Icons.badge_outlined,
controller: _cfController,
),
),
],
),
const SizedBox(height: 16),
FluxTextField(
label: 'Codice Univoco (SDI) / PEC',
icon: Icons.send_and_archive_outlined,
controller: _univocoController,
),
const SizedBox(height: 32),
// --- SEZIONE 2: SEDE LEGALE ---
_SectionTitle(title: 'SEDE LEGALE'),
const SizedBox(height: 16),
FluxTextField(
label: 'Indirizzo e n. civico',
icon: Icons.home_work_outlined,
controller: _indirizzoController,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
flex: 2,
child: FluxTextField(
label: 'Città',
icon: Icons.location_city,
controller: _cittaController,
),
),
const SizedBox(width: 12),
Expanded(
child: FluxTextField(
label: 'CAP',
icon: Icons.map_outlined,
controller: _capController,
),
),
const SizedBox(width: 12),
Expanded(
child: FluxTextField(
label: 'Prov',
icon: Icons.explore_outlined,
controller: _provinciaController,
),
),
],
),
const SizedBox(height: 32),
// --- SEZIONE 3: LOGO AZIENDALE ---
_SectionTitle(title: 'BRANDING'),
const SizedBox(height: 16),
_buildLogoPicker(context),
const SizedBox(height: 48),
// --- BOTTONE INVIO ---
_buildSubmitButton(context, state),
],
),
),
),
);
},
),
);
}
// Placeholder per il futuro caricamento logo
Widget _buildLogoPicker(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: context.accent.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
// Bordo continuo ma sottile e semitrasparente per un look pulito
border: Border.all(
color: context.accent.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
children: [
Icon(Icons.cloud_upload_outlined, color: context.accent, size: 32),
const SizedBox(height: 12),
Text(
'Carica Logo Aziendale',
style: TextStyle(
color: context.primaryText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Verrà usato per le tue stampe e ricevute',
textAlign: TextAlign.center,
style: TextStyle(color: context.secondaryText, fontSize: 12),
),
],
),
);
}
Widget _buildSubmitButton(BuildContext context, CompanyState state) {
return SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: state.status == CompanyStatus.loading
? null
: () => _onSave(),
child: state.status == CompanyStatus.loading
? const CircularProgressIndicator()
: const Text('SALVA AZIENDA'),
),
);
}
Widget _buildHeader(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.accent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.domain_add_rounded,
color: context.accent,
size: 32,
),
),
const SizedBox(height: 24),
Text(
'Configura la tua Azienda',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: context.primaryText,
),
),
const SizedBox(height: 12),
Text(
'FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi.',
style: TextStyle(
color: context.secondaryText,
fontSize: 15,
height: 1.5,
),
),
],
);
}
}
// Widget di supporto per i titoli delle sezioni
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) {
return Text(
title,
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
fontSize: 13,
),
);
}
}

View File

@@ -1,161 +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_file_model.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart';
part 'customer_state.dart';
class CustomerCubit extends Cubit<CustomerState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
// Variabile per gestire il debounce della ricerca
Timer? _searchDebounce;
CustomerCubit() : super(const CustomerState());
// --- LETTURA ---
Future<void> loadCustomers() async {
emit(state.copyWith(status: CustomerStatus.loading));
try {
final customers = await _repository.getCustomers(
_sessionCubit.state.company!.id!,
);
emit(
state.copyWith(status: CustomerStatus.success, customers: customers),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- CREAZIONE ---
Future<void> createCustomer(CustomerModel customer) async {
emit(state.copyWith(status: CustomerStatus.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: CustomerStatus.success,
customers: updatedList,
lastCreatedCustomer: newCustomer,
),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- AGGIORNAMENTO ---
Future<void> updateCustomer(CustomerModel customer) async {
emit(state.copyWith(status: CustomerStatus.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: CustomerStatus.success,
customers: updatedList,
lastCreatedCustomer:
updatedCustomer, // Utile se modifichi un cliente appena creato
),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.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: CustomerStatus.success, customers: results),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
errorMessage: e.toString(),
),
);
}
});
}
Future<CustomerModel?> quickCreateCustomer({
required String name,
String? phone,
String? email,
}) async {
final newCustomer = CustomerModel(
nome: name,
telefono: 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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 'customer_cubit.dart'; part of 'customers_list_cubit.dart';
enum CustomerStatus { enum CustomersListStatus {
initial, initial,
loading, loading,
filesLoading, filesLoading,
@@ -9,34 +9,30 @@ enum CustomerStatus {
failure, failure,
} }
class CustomerState extends Equatable { class CustomersListState extends Equatable {
final CustomerStatus status; final CustomersListStatus status;
final List<CustomerModel> customers; final List<CustomerModel> customers;
final CustomerModel? lastCreatedCustomer; final CustomerModel? lastCreatedCustomer;
final String? errorMessage; final String? errorMessage;
final List<CustomerFileModel> customerFiles;
const CustomerState({ const CustomersListState({
this.status = CustomerStatus.initial, this.status = CustomersListStatus.initial,
this.customers = const [], this.customers = const [],
this.lastCreatedCustomer, this.lastCreatedCustomer,
this.errorMessage, this.errorMessage,
this.customerFiles = const [],
}); });
CustomerState copyWith({ CustomersListState copyWith({
CustomerStatus? status, CustomersListStatus? status,
List<CustomerModel>? customers, List<CustomerModel>? customers,
CustomerModel? lastCreatedCustomer, CustomerModel? lastCreatedCustomer,
String? errorMessage, String? errorMessage,
List<CustomerFileModel>? customerFiles,
}) { }) {
return CustomerState( 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,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
customerFiles: customerFiles ?? this.customerFiles,
); );
} }
@@ -46,6 +42,5 @@ class CustomerState extends Equatable {
customers, customers,
lastCreatedCustomer, lastCreatedCustomer,
errorMessage, errorMessage,
customerFiles,
]; ];
} }

View File

@@ -1,8 +1,8 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/core/enums_and_consts/consts.dart';
import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.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/customer_model.dart'; import '../models/customer_model.dart';
@@ -12,30 +12,30 @@ 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();
return CustomerModel.fromMap(response); return CustomerModel.fromMap(response);
} catch (e) { } catch (e) {
throw 'Errore durante il salvataggio del cliente: $e'; throw '$e';
} }
} }
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()
.single(); .single();
return CustomerModel.fromMap(response); return CustomerModel.fromMap(response);
} catch (e) { } catch (e) {
throw 'Errore durante la modifica del cliente: $e'; throw '$e';
} }
} }
@@ -43,18 +43,35 @@ 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('''
*, *,
customer_file(*) ${Tables.attachments}(*)
''') ''')
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('is_active', true) .eq('is_active', true)
.order('nome'); .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) {
throw 'Errore nel recupero clienti'; throw '$e';
}
}
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';
} }
} }
@@ -65,10 +82,10 @@ 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('nome.ilike.%$query%,telefono.ilike.%$query%') .or('name.ilike.%$query%,phone_number.ilike.%$query%')
.limit(10); .limit(10);
return (response as List).map((c) => CustomerModel.fromMap(c)).toList(); return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
@@ -78,36 +95,34 @@ class CustomerRepository {
} }
/// Ascolta in tempo reale i file caricati per un cliente /// Ascolta in tempo reale i file caricati per un cliente
Stream<List<CustomerFileModel>> getCustomerFilesStream(String customerId) { Stream<List<AttachmentModel>> getCustomerFilesStream(String customerId) {
return _supabase return _supabase
.from('customer_file') .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)
.map( .map(
(listOfMaps) => (listOfMaps) =>
listOfMaps.map((map) => CustomerFileModel.fromMap(map)).toList(), listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
); );
} }
/// Recupera i file di un cliente specifico /// Recupera i file di un cliente specifico
Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async { Future<List<AttachmentModel>> getCustomerFiles(String customerId) async {
try { try {
final response = await _supabase final response = await _supabase
.from('customer_file') .from(Tables.attachments)
.select() .select()
.eq('customer_id', customerId); .eq('customer_id', customerId);
return (response as List) return (response as List).map((f) => AttachmentModel.fromMap(f)).toList();
.map((f) => CustomerFileModel.fromMap(f))
.toList();
} catch (e) { } catch (e) {
throw 'Errore recupero file: $e'; throw '$e';
} }
} }
/// Carica un file e salva il riferimento nel database /// Carica un file e salva il riferimento nel database
Future<CustomerFileModel> uploadAndRegisterFile({ Future<AttachmentModel> uploadAndRegisterFile({
required String customerId, required String customerId,
required PlatformFile pickedFile, required PlatformFile pickedFile,
}) async { }) async {
@@ -118,7 +133,8 @@ class CustomerRepository {
final storagePath = final storagePath =
'$companyId/customers/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName'; '$companyId/customers/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
final int fileSize = pickedFile.size; final int fileSize = pickedFile.size;
final fileToSave = CustomerFileModel( final fileToSave = AttachmentModel(
companyId: companyId,
customerId: customerId, customerId: customerId,
name: cleanFileName.fileNameWithoutExtension(), name: cleanFileName.fileNameWithoutExtension(),
extension: cleanFileName.fileExtension(), extension: cleanFileName.fileExtension(),
@@ -131,7 +147,7 @@ class CustomerRepository {
try { try {
// Usiamo bytes invece del path per massima compatibilità // Usiamo bytes invece del path per massima compatibilità
if (pickedFile.bytes == null && pickedFile.path == null) { if (pickedFile.bytes == null && pickedFile.path == null) {
throw 'Impossibile leggere il contenuto del file'; throw 'File read error';
} }
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes // Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
@@ -146,54 +162,54 @@ class CustomerRepository {
} }
final response = await _supabase final response = await _supabase
.from('customer_file') .from(Tables.attachments)
.insert(fileToSave.toMap()) .insert(fileToSave.toMap())
.select() .select()
.single(); .single();
return CustomerFileModel.fromMap(response); return AttachmentModel.fromMap(response);
} catch (e) { } catch (e) {
throw 'Errore durante l\'upload: $e'; throw '$e';
} }
} }
Future<void> saveFileReference(CustomerFileModel file) async { Future<void> saveFileReference(AttachmentModel file) async {
await _supabase.from('customer_file').upsert(file.toMap()); await _supabase.from(Tables.attachments).upsert(file.toMap());
} }
/// Aggiorna la lista degli URL nel database Future<void> deleteDocuments(List<AttachmentModel> files) async {
Future<void> updateCustomerDocuments(int id, List<String> urls) async {
await _supabase
.from('customer')
.update({'document_urls': urls})
.eq('id', id);
}
Future<void> deleteDocuments(List<CustomerFileModel> files) async {
if (files.isEmpty) return; if (files.isEmpty) return;
// 1. Prepariamo le liste di ID e di Percorsi // 1. Prepariamo le liste di ID e di Percorsi
final List<String> idsToDelete = files.map((f) => f.id!).toList(); final List<String> idsToDelete = [];
final List<String> storagePaths = files.map((f) => f.storagePath).toList(); final List<String> storagePathsToDelete = [];
final List<String> idsToEdit = [];
for (var file in files) {
if (file.operationId == null) {
idsToDelete.add(file.id!);
storagePathsToDelete.add(file.storagePath!);
} else {
idsToEdit.add(file.id!);
}
}
try { try {
// 2. Cancellazione MASSIVA dal DB (una sola chiamata invece di un ciclo!) if (idsToDelete.isNotEmpty) {
// .in_ dice: "cancella tutti i record il cui ID è contenuto in questa lista" await _supabase
await _supabase .from(Tables.attachments)
.from('customer_file') .delete()
.delete() .inFilter('id', idsToDelete);
.inFilter('id', idsToDelete); // 3. Cancellazione MASSIVA dallo Storage
await _supabase.storage.from('documents').remove(storagePathsToDelete);
// 3. Cancellazione MASSIVA dallo Storage }
await _supabase.storage.from('documents').remove(storagePaths); if (idsToEdit.isNotEmpty) {
await _supabase
debugPrint("Eliminati con successo ${files.length} file."); .from(Tables.attachments)
.update({'customer_id': null})
.inFilter('id', idsToEdit);
}
} on PostgrestException catch (e) { } on PostgrestException catch (e) {
debugPrint("Errore DB: ${e.message}"); throw e.message;
throw 'Errore database: ${e.message}';
} catch (e) { } catch (e) {
debugPrint("Errore generico: $e"); throw '$e';
throw 'Errore durante l\'eliminazione dei file: $e';
} }
} }
} }

View File

@@ -1,91 +0,0 @@
import 'package:equatable/equatable.dart';
class CustomerFileModel extends Equatable {
final String? id;
final String customerId; // Riferimento UUID
final String name;
final String storagePath;
final String extension;
final DateTime? createdAt;
final int fileSize;
const CustomerFileModel({
this.id,
required this.customerId,
required this.name,
required this.storagePath,
required this.extension,
this.createdAt,
required this.fileSize,
});
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
String get sizeFormatted {
if (fileSize <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"];
var i = (fileSize.toString().length - 1) ~/ 3;
if (i >= suffixes.length) i = suffixes.length - 1;
double num = fileSize / (1 << (i * 10));
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
}
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
CustomerFileModel copyWith({
String? id,
String? customerId,
String? name,
String? storagePath,
String? extension,
DateTime? createdAt,
int? fileSize,
}) {
return CustomerFileModel(
id: id ?? this.id,
customerId: customerId ?? this.customerId,
name: name ?? this.name,
storagePath: storagePath ?? this.storagePath,
extension: extension ?? this.extension,
createdAt: createdAt ?? this.createdAt,
fileSize: fileSize ?? this.fileSize,
);
}
factory CustomerFileModel.fromMap(Map<String, dynamic> map) {
return CustomerFileModel(
id: map['id'] as String,
customerId: map['customer_id'],
name: map['name'],
storagePath: map['storage_path'],
extension: map['extension'] ?? '',
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
fileSize: map['file_size'] is int
? map['file_size']
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'customer_id': customerId,
'name': name,
'storage_path': storagePath,
'extension': extension,
'file_size': fileSize,
};
}
@override
List<Object?> get props => [
id,
customerId,
name,
storagePath,
extension,
createdAt,
fileSize,
];
}

View File

@@ -1,74 +1,88 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:flux/features/attachments/models/attachment_model.dart';
class CustomerModel extends Equatable { class CustomerModel extends Equatable {
final String? id; // Bigint in SQL final String? id; // Bigint in SQL
final DateTime? createdAt; final DateTime? createdAt;
final String nome; final String name;
final String telefono; final String phoneNumber;
final String email; final String email;
final String note; final String note;
final DateTime? dataUltimoContatto; final DateTime? lastContactDate;
final bool nonDisturbare; final bool doNotDisturb;
final String companyId; // UUID final String companyId; // UUID
final bool isActive; final bool isActive;
final List<CustomerFileModel> files; final List<AttachmentModel> attachments;
final bool isBusiness;
const CustomerModel({ const CustomerModel({
this.id, this.id,
this.createdAt, this.createdAt,
required this.nome, required this.name,
required this.telefono, required this.phoneNumber,
required this.email, required this.email,
required this.note, required this.note,
this.dataUltimoContatto, this.lastContactDate,
this.nonDisturbare = false, this.doNotDisturb = false,
required this.companyId, required this.companyId,
this.isActive = true, this.isActive = true,
this.files = const [], this.attachments = const [],
this.isBusiness = false,
}); });
@override @override
List<Object?> get props => [ List<Object?> get props => [
id, id,
createdAt, createdAt,
nome, name,
telefono, phoneNumber,
email, email,
note, note,
dataUltimoContatto, lastContactDate,
nonDisturbare, doNotDisturb,
companyId, companyId,
isActive, isActive,
files, 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,
String? nome, String? name,
String? telefono, String? phoneNumber,
String? email, String? email,
String? note, String? note,
DateTime? dataUltimoContatto, DateTime? lastContactDate,
bool? nonDisturbare, bool? doNotDisturb,
String? companyId, String? companyId,
bool? isActive, bool? isActive,
List<CustomerFileModel>? files, List<AttachmentModel>? attachments,
bool? isBusiness,
}) { }) {
return CustomerModel( return CustomerModel(
id: id ?? this.id, id: id ?? this.id,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
nome: nome ?? this.nome, name: name ?? this.name,
telefono: telefono ?? this.telefono, phoneNumber: phoneNumber ?? this.phoneNumber,
email: email ?? this.email, email: email ?? this.email,
note: note ?? this.note, note: note ?? this.note,
dataUltimoContatto: dataUltimoContatto ?? this.dataUltimoContatto, lastContactDate: lastContactDate ?? this.lastContactDate,
nonDisturbare: nonDisturbare ?? this.nonDisturbare, doNotDisturb: doNotDisturb ?? this.doNotDisturb,
companyId: companyId ?? this.companyId, companyId: companyId ?? this.companyId,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
files: files ?? this.files, attachments: attachments ?? this.attachments,
isBusiness: isBusiness ?? this.isBusiness,
); );
} }
@@ -78,36 +92,38 @@ class CustomerModel extends Equatable {
createdAt: map['created_at'] != null createdAt: map['created_at'] != null
? DateTime.parse(map['created_at']) ? DateTime.parse(map['created_at'])
: null, : null,
nome: (map['nome'] as String).myFormat(), name: (map['name'] as String).myFormat(),
telefono: map['telefono'], phoneNumber: map['phone_number'],
email: map['email'], email: map['email'],
note: map['note'] ?? '', note: map['note'] ?? '',
dataUltimoContatto: map['data_ultimo_contatto'] != null lastContactDate: map['last_contact_date'] != null
? DateTime.parse(map['data_ultimo_contatto']) ? DateTime.parse(map['last_contact_date'])
: null, : null,
nonDisturbare: map['non_disturbare'] ?? false, doNotDisturb: map['do_not_disturb'] ?? false,
companyId: map['company_id'] as String, companyId: map['company_id'] as String,
isActive: map['is_active'] ?? true, isActive: map['is_active'] ?? true,
files: attachments:
(map['customer_file'] as List?) (map['attachment'] as List?)
?.map((x) => CustomerFileModel.fromMap(x)) ?.map((x) => AttachmentModel.fromMap(x))
.toList() ?? .toList() ??
const [], const [],
isBusiness: map['is_business'] as bool? ?? false,
); );
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
if (id != null) 'id': id, if (id != null) 'id': id,
'nome': nome.toLowerCase().trim(), 'name': name.toLowerCase().trim(),
'telefono': telefono, 'phone_number': phoneNumber,
'email': email.toLowerCase().trim(), 'email': email.toLowerCase().trim(),
'note': note, 'note': note,
if (dataUltimoContatto != null) if (lastContactDate != null)
'data_ultimo_contatto': dataUltimoContatto!.toIso8601String(), 'last_contact_date': lastContactDate!.toIso8601String(),
'non_disturbare': nonDisturbare, 'do_not_disturb': doNotDisturb,
'company_id': companyId, 'company_id': companyId,
'is_active': isActive, 'is_active': isActive,
'is_business': isBusiness,
}; };
} }
} }

View File

@@ -6,9 +6,10 @@ import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/widgets/image_viewer_widget.dart'; import 'package:flux/core/widgets/image_viewer_widget.dart';
import 'package:flux/core/widgets/pdf_viewer_widget.dart'; import 'package:flux/core/widgets/pdf_viewer_widget.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart'; import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:get_it/get_it.dart';
class CustomerDetailScreen extends StatefulWidget { class CustomerDetailScreen extends StatefulWidget {
final CustomerModel customer; final CustomerModel customer;
@@ -26,11 +27,13 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
} }
void _loadFiles() { void _loadFiles() {
context.read<CustomerFilesBloc>().add(LoadCustomerFilesEvent()); context.read<AttachmentsBloc>().add(
LoadAttachmentsEvent(parentId: widget.customer.id),
);
} }
Future<void> _pickAndUpload() async { Future<void> _pickAndUpload() async {
CustomerFilesBloc customerFilesBloc = context.read<CustomerFilesBloc>(); AttachmentsBloc attachmentsBloc = context.read<AttachmentsBloc>();
// Chiamata statica pulita // Chiamata statica pulita
FilePickerResult? result = await FilePicker.pickFiles( FilePickerResult? result = await FilePicker.pickFiles(
@@ -40,17 +43,18 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
); );
if (result != null) { if (result != null) {
for (var pickedFile in result.files) { try {
try { attachmentsBloc.add(
customerFilesBloc.add( UploadAttachmentsEvent(
UploadCustomerFileEvent(pickedFile: pickedFile), pickedFiles: result.files,
); companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
} catch (e) { ),
if (mounted) { );
ScaffoldMessenger.of(context).showSnackBar( } catch (e) {
SnackBar(content: Text("Errore upload ${pickedFile.name}: $e")), if (mounted) {
); ScaffoldMessenger.of(
} context,
).showSnackBar(SnackBar(content: Text("$e")));
} }
} }
} }
@@ -62,7 +66,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
backgroundColor: context.background, backgroundColor: context.background,
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
widget.customer.nome, widget.customer.name,
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
backgroundColor: context.background, backgroundColor: context.background,
@@ -103,7 +107,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_infoTile(Icons.phone_android, "Telefono", widget.customer.telefono), _infoTile(Icons.phone_android, "Telefono", widget.customer.phoneNumber),
_infoTile( _infoTile(
Icons.email_outlined, Icons.email_outlined,
"Email", "Email",
@@ -117,7 +121,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
: widget.customer.note, : widget.customer.note,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
if (widget.customer.nonDisturbare) if (widget.customer.doNotDisturb)
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -143,7 +147,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
} }
Widget _buildDocumentSection() { Widget _buildDocumentSection() {
return BlocBuilder<CustomerFilesBloc, CustomerFilesState>( return BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) { builder: (context, state) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -191,8 +195,8 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
context: context, context: context,
builder: (context) => QrUploadDialog( builder: (context) => QrUploadDialog(
deepLinkUrl: deepLinkUrl:
'fluxapp://customer/${widget.customer.id}/upload?name=${Uri.encodeComponent(widget.customer.nome)}', 'fluxapp://customer/${widget.customer.id}/upload?name=${Uri.encodeComponent(widget.customer.name)}',
title: 'Scatta per ${widget.customer.nome}', title: 'Scatta per ${widget.customer.name}',
), ),
); );
}, },
@@ -213,9 +217,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
if (state.status == CustomerFilesStatus.loading) if (state.status == AttachmentsStatus.loading)
const Center(child: CircularProgressIndicator()) const Center(child: CircularProgressIndicator())
else if (state.customerFiles.isEmpty) else if (state.allFiles.isEmpty)
const Center(child: Text("Nessun documento presente")) const Center(child: Text("Nessun documento presente"))
else else
Expanded( Expanded(
@@ -226,9 +230,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
crossAxisSpacing: 16, crossAxisSpacing: 16,
childAspectRatio: 1.2, childAspectRatio: 1.2,
), ),
itemCount: state.customerFiles.length, itemCount: state.allFiles.length,
itemBuilder: (context, index) => itemBuilder: (context, index) =>
_FileCard(file: state.customerFiles[index], state: state), _FileCard(file: state.allFiles[index], state: state),
), ),
), ),
], ],
@@ -262,20 +266,20 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
void _showDeleteConfirmationDialog({ void _showDeleteConfirmationDialog({
required BuildContext context, required BuildContext context,
required List<CustomerFileModel> files, required List<AttachmentModel> files,
}) {} }) {}
} }
class _FileCard extends StatelessWidget { class _FileCard extends StatelessWidget {
final CustomerFileModel file; final AttachmentModel file;
final CustomerFilesState state; final AttachmentsState state;
const _FileCard({required this.file, required this.state}); const _FileCard({required this.file, required this.state});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: () => context.read<CustomerFilesBloc>().add( onTap: () => context.read<AttachmentsBloc>().add(
ToggleCustomerFileSelectionEvent(file), ToggleAttachmentSelectionEvent(file),
), ),
onDoubleTap: () => _handleDoubleClickOnFile(context, file), onDoubleTap: () => _handleDoubleClickOnFile(context, file),
child: Stack( child: Stack(
@@ -334,7 +338,7 @@ class _FileCard extends StatelessWidget {
} }
} }
void _handleDoubleClickOnFile(BuildContext context, CustomerFileModel file) { void _handleDoubleClickOnFile(BuildContext context, AttachmentModel file) {
showDialog( showDialog(
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,

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?.nome ?? '');
_telefonoController = TextEditingController(
text: widget.customer?.telefono ?? '',
);
_emailController = TextEditingController(
text: widget.customer?.email ?? '',
);
_noteController = TextEditingController(text: widget.customer?.note ?? '');
_nonDisturbare = widget.customer?.nonDisturbare ?? 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(
nome: _nomeController.text.trim(),
telefono: _telefonoController.text.trim(),
email: _emailController.text.trim(),
note: _noteController.text.trim(),
nonDisturbare: _nonDisturbare,
) ??
CustomerModel(
// Caso nuovo cliente
nome: _nomeController.text.trim(),
telefono: _telefonoController.text.trim(),
email: _emailController.text.trim(),
note: _noteController.text.trim(),
nonDisturbare: _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',
),
),
),
],
),
),
),
),
);
},
);
}
}

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