Compare commits
106 Commits
1d3a685368
...
refactor-n
| Author | SHA1 | Date | |
|---|---|---|---|
| 123c006a1e | |||
| 415811f592 | |||
| 31066a4d8f | |||
| b700c2de8d | |||
| fda5b8fe2e | |||
| b7a525056a | |||
| 7a11e829b3 | |||
| 361b61a694 | |||
| 0cb060c89c | |||
| 4b9cbf65f9 | |||
| 813fc9dd38 | |||
| f574d6197b | |||
| 2fac3117a4 | |||
| 7b072a219d | |||
| 23d3356e6b | |||
| 5b2702daed | |||
| b9c3eb7091 | |||
| 6fbc5d947c | |||
| f520a02226 | |||
| 3a43b2672a | |||
| 61959a5a2e | |||
| 5f16ee2b38 | |||
| a8ebb1dada | |||
| 862719b8b0 | |||
| d1ee6d8a10 | |||
| c3268012a5 | |||
| da24b6a5ed | |||
| 8b8dd0a427 | |||
| 979ab5e86d | |||
| 9703cb5ce8 | |||
| c85f4b086e | |||
| f190ad9353 | |||
| 659963beb0 | |||
| d3b1e52d88 | |||
| 3c0880f527 | |||
| 8a1b582f4e | |||
| 364474471c | |||
| 3ecf617998 | |||
| 3f2f55d6c2 | |||
| 4e03d52a5d | |||
| 2bdba523ad | |||
| 716de36bfa | |||
| 00d5890a37 | |||
| ecb161bc07 | |||
| 1ee4a3bf45 | |||
| 5e99324201 | |||
| b06a655bc3 | |||
| 906265a0e3 | |||
| 1a21b44bc8 | |||
| a8c9e0f253 | |||
| 491a857f61 | |||
| b3f463b688 | |||
| 9a5d0e33bd | |||
| a166992b04 | |||
| b5ccb0428d | |||
| f4a8314978 | |||
| f19f19a279 | |||
| ad35f641b3 | |||
| 6c892bf580 | |||
| 89099c2cfd | |||
| 0f9616f19a | |||
| 3b3cfb5e43 | |||
| 24004a99da | |||
| ab7601a74e | |||
| f09606e1f7 | |||
| c610d68b9c | |||
| efb82b0d4a | |||
| 216fd85888 | |||
| 2aab70aec5 | |||
| 57061da20d | |||
| cbc5387097 | |||
| e52dbee835 | |||
| 1dee51a7cd | |||
| a76180497e | |||
| 5c86483563 | |||
| 385c3da0a5 | |||
| 5f39d5b1ad | |||
| 1081609530 | |||
| 901f63841f | |||
| 27a262b54a | |||
| a81515e4d8 | |||
| 73c5751677 | |||
| 0171ee6141 | |||
| 1ee2758756 | |||
| fbb21dd8a4 | |||
| 45d49b38f7 | |||
| 91a7663681 | |||
| 302bec114f | |||
| 65aa3c7de8 | |||
| c6ef798b22 | |||
| 42a9506f02 | |||
| 9793ba8348 | |||
| fbf18acf05 | |||
| 5c1f9c0ebc | |||
| 4cc1c9d157 | |||
| 7d03d0dea5 | |||
| 94ad524bae | |||
| 9f57207a39 | |||
| 1dff8ab90d | |||
| fe5d1bd9e4 | |||
| d2ff2de673 | |||
| 32c97accd7 | |||
| f86b52e236 | |||
| c49964e5c5 | |||
| 77987258aa | |||
| 6cbf0479a1 |
90
.gitea/workflows/release.yaml
Normal 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
@@ -3,9 +3,10 @@
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
*.env
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.build/*
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
|
||||
4
.wrangler/cache/pages.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"account_id": "6badf20faeef39fa5c99283f46f07508",
|
||||
"project_name": "flux"
|
||||
}
|
||||
6
.wrangler/cache/wrangler-account.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"account": {
|
||||
"id": "6badf20faeef39fa5c99283f46f07508",
|
||||
"name": "Marco@catelli.it's Account"
|
||||
}
|
||||
}
|
||||
@@ -1 +1,16 @@
|
||||
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
|
||||
@@ -2,7 +2,7 @@
|
||||
<application
|
||||
android:label="flux"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/launcher_icon">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -24,12 +24,12 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter android:label="flux_deep_link">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="fluxapp" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="flux" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
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
|
After Width: | Height: | Size: 109 KiB |
34
flutter_launcher_icons.yaml
Normal 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"
|
||||
@@ -543,7 +543,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
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_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -600,7 +600,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
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_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
||||
@@ -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" : "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"
|
||||
}
|
||||
}
|
||||
{"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"}}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 839 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 7.6 KiB |
3
l10n.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
arb-dir: lib/l10n
|
||||
template-arb-file: app_it.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
@@ -39,8 +39,35 @@ class SessionCubit extends Cubit<SessionState> {
|
||||
}
|
||||
|
||||
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
|
||||
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) {
|
||||
return emit(
|
||||
state.copyWith(
|
||||
@@ -69,7 +96,6 @@ class SessionCubit extends Cubit<SessionState> {
|
||||
}
|
||||
|
||||
// 3. Controllo Staff (Paziente Zero)
|
||||
final staff = await _repository.getStaffMemberByUserId(user.id);
|
||||
if (staff == null) {
|
||||
return emit(
|
||||
state.copyWith(
|
||||
@@ -95,6 +121,8 @@ class SessionCubit extends Cubit<SessionState> {
|
||||
await _prefs.setString(_lastStoreKey, activeStore.id!);
|
||||
}
|
||||
|
||||
setIsSingleUserMode(_prefs.getBool('isSingleUserMode') ?? false);
|
||||
|
||||
// 4. BENVENUTO A BORDO
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -102,7 +130,7 @@ class SessionCubit extends Cubit<SessionState> {
|
||||
user: user,
|
||||
company: company,
|
||||
currentStore: activeStore,
|
||||
currentStaff: staff,
|
||||
currentStaffMember: staff,
|
||||
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 ---
|
||||
Future<void> changeStore(StoreModel newStore) async {
|
||||
if (newStore.id != null) {
|
||||
@@ -134,4 +166,8 @@ class SessionCubit extends Cubit<SessionState> {
|
||||
void setIsMobileDevice(bool isMobile) {
|
||||
emit(state.copyWith(isMobileDevice: isMobile));
|
||||
}
|
||||
|
||||
void setIsSingleUserMode(bool isSingleUser) {
|
||||
emit(state.copyWith(isSingleUserMode: isSingleUser));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,18 +22,20 @@ class SessionState extends Equatable {
|
||||
final User? user; // Utente di Supabase Auth
|
||||
final CompanyModel? company;
|
||||
final StoreModel? currentStore;
|
||||
final StaffMemberModel? currentStaff;
|
||||
final StaffMemberModel? currentStaffMember;
|
||||
final OnboardingStep onboardingStep;
|
||||
final bool isMobileDevice;
|
||||
final bool isSingleUserMode;
|
||||
|
||||
const SessionState({
|
||||
this.status = SessionStatus.initial,
|
||||
this.user,
|
||||
this.company,
|
||||
this.currentStore,
|
||||
this.currentStaff,
|
||||
this.currentStaffMember,
|
||||
this.onboardingStep = OnboardingStep.none,
|
||||
this.isMobileDevice = false,
|
||||
this.isSingleUserMode = false,
|
||||
});
|
||||
|
||||
/// Metodo per creare una copia dello stato modificando solo i campi necessari
|
||||
@@ -42,18 +44,20 @@ class SessionState extends Equatable {
|
||||
User? user,
|
||||
CompanyModel? company,
|
||||
StoreModel? currentStore,
|
||||
StaffMemberModel? currentStaff,
|
||||
StaffMemberModel? currentStaffMember,
|
||||
OnboardingStep? onboardingStep,
|
||||
bool? isMobileDevice,
|
||||
bool? isSingleUserMode,
|
||||
}) {
|
||||
return SessionState(
|
||||
status: status ?? this.status,
|
||||
user: user ?? this.user,
|
||||
company: company ?? this.company,
|
||||
currentStore: currentStore ?? this.currentStore,
|
||||
currentStaff: currentStaff ?? this.currentStaff,
|
||||
currentStaffMember: currentStaffMember ?? this.currentStaffMember,
|
||||
onboardingStep: onboardingStep ?? this.onboardingStep,
|
||||
isMobileDevice: isMobileDevice ?? this.isMobileDevice,
|
||||
isSingleUserMode: isSingleUserMode ?? this.isSingleUserMode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,9 +67,10 @@ class SessionState extends Equatable {
|
||||
user,
|
||||
company,
|
||||
currentStore,
|
||||
currentStaff,
|
||||
currentStaffMember,
|
||||
onboardingStep,
|
||||
isMobileDevice,
|
||||
isSingleUserMode,
|
||||
];
|
||||
|
||||
// Helper rapidi per la UI
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/foundation.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/master_data/store/models/store_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 {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('company')
|
||||
.from(Tables.companies)
|
||||
.select()
|
||||
.eq('user_id', userId) // <-- Assicurati di avere questo campo nel DB!
|
||||
.maybeSingle();
|
||||
@@ -24,30 +25,45 @@ class CoreRepository {
|
||||
return CompanyModel.fromMap(response);
|
||||
} catch (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 {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('store')
|
||||
.from(Tables.stores)
|
||||
.select()
|
||||
.eq('company_id', companyId)
|
||||
.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();
|
||||
} catch (e) {
|
||||
debugPrint('Errore recupero negozi: $e');
|
||||
throw Exception('Errore recupero negozi: $e');
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<StaffMemberModel?> getStaffMemberByUserId(String userId) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('staff_member')
|
||||
.from(Tables.staffMembers)
|
||||
.select()
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
@@ -56,7 +72,7 @@ class CoreRepository {
|
||||
return StaffMemberModel.fromMap(response);
|
||||
} catch (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 {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('company')
|
||||
.from(Tables.companies)
|
||||
.insert(company.toMap())
|
||||
.select()
|
||||
.single();
|
||||
return CompanyModel.fromMap(response);
|
||||
} catch (e) {
|
||||
debugPrint('Creazione azienda fallita: $e');
|
||||
throw Exception('Creazione azienda fallita: $e');
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<StoreModel> createStore(StoreModel store) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('store')
|
||||
.from(Tables.stores)
|
||||
.insert(store.toMap())
|
||||
.select()
|
||||
.single();
|
||||
return StoreModel.fromMap(response);
|
||||
} catch (e) {
|
||||
debugPrint('Creazione negozio fallita: $e');
|
||||
throw Exception('Creazione negozio fallita: $e');
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<StaffMemberModel> createStaffMember(StaffMemberModel staff) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('staff_member')
|
||||
.from(Tables.staffMembers)
|
||||
.insert(staff.toMap())
|
||||
.select()
|
||||
.single();
|
||||
final StaffMemberModel staffMember = StaffMemberModel.fromMap(response);
|
||||
await _supabase.from('staff_in_stores').insert({
|
||||
await _supabase.from(Tables.staffInStores).insert({
|
||||
'staff_member_id': staffMember.id,
|
||||
'store_id': GetIt.I.get<SessionCubit>().state.currentStore!.id,
|
||||
});
|
||||
return StaffMemberModel.fromMap(response);
|
||||
} catch (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);
|
||||
}
|
||||
}
|
||||
|
||||
24
lib/core/enums_and_consts/consts.dart
Normal 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/';
|
||||
370
lib/core/layout/app_shell.dart
Normal 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);
|
||||
}
|
||||
@@ -1,23 +1,54 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.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/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/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/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/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/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/ui/onboarding_screen.dart';
|
||||
import 'package:flux/features/services/blocs/service_files_bloc.dart';
|
||||
import 'package:flux/features/services/models/service_model.dart';
|
||||
import 'package:flux/features/services/ui/service_form_screen/service_form_screen.dart';
|
||||
import 'package:flux/features/services/ui/service_form_screen/service_mobile_upload_screen.dart';
|
||||
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:flux/features/operations/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:go_router/go_router.dart';
|
||||
|
||||
@@ -25,55 +56,63 @@ class AppRouter {
|
||||
static GoRouter createRouter(SessionCubit sessionCubit) {
|
||||
return GoRouter(
|
||||
initialLocation: '/',
|
||||
// MAGIA 1: Il router "ascolta" ogni singolo respiro del SessionCubit
|
||||
refreshListenable: GoRouterRefreshStream(sessionCubit.stream),
|
||||
|
||||
// MAGIA 2: Il Buttafuori Supremo
|
||||
redirect: (context, state) {
|
||||
final sessionState = sessionCubit.state;
|
||||
final isGoingToLogin = state.matchedLocation == '/login';
|
||||
final isGoingToOnboarding = state.matchedLocation == '/onboarding';
|
||||
final isGoingToSetPassword = state.matchedLocation == '/set-password';
|
||||
|
||||
// Caso 1: L'app si sta ancora avviando.
|
||||
// Restituiamo null per farlo rimanere sulla SplashScreen del main.dart
|
||||
if (sessionState.status == SessionStatus.initial) {
|
||||
// 1. LA PASSATOIA VIP (DEVE ESSERE IN CIMA)
|
||||
// Usiamo state.uri.path perché state.matchedLocation a volte fa i capricci coi deep link iniziali
|
||||
final isPublicRoute = state.uri.path.startsWith('/upload');
|
||||
|
||||
if (isPublicRoute) {
|
||||
// Ritorna null esplicitamente per dire al router "Rimani qui e non fare altri controlli"
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Se sta già andando al login, lascialo andare. Altrimenti, forzalo al login.
|
||||
return isGoingToLogin ? null : '/login';
|
||||
// Se sta già andando alle uniche altre pagine pubbliche, lascialo andare
|
||||
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) {
|
||||
// Se sta già andando all'onboarding, ok. Altrimenti forzalo lì.
|
||||
// Non può "scappare" digitando l'URL della dashboard!
|
||||
return isGoingToOnboarding ? null : '/onboarding';
|
||||
}
|
||||
|
||||
// Caso 4: Utente loggato e configurato (Tutto OK!)
|
||||
// 5. UTENTE PERFETTAMENTE LOGGATO E OPERATIVO
|
||||
if (sessionState.status == SessionStatus.authenticated) {
|
||||
// Se per sbaglio cerca di tornare al login o all'onboarding,
|
||||
// lo rimbalziamo alla home.
|
||||
if (isGoingToLogin || isGoingToOnboarding) {
|
||||
return '/';
|
||||
}
|
||||
// Per tutte le altre rotte (dashboard, clienti, anagrafiche), lascialo passare.
|
||||
// Se per sbaglio cerca di tornare al login o all'onboarding, ributtalo in dashboard
|
||||
if (isGoingToLogin || isGoingToOnboarding) return '/';
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
// --- ROTTE DI SERVIZIO (FUORI DALLA SHELL) ---
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
//builder: (context, state) => const LoginScreen(),
|
||||
name: Routes.login,
|
||||
builder: (context, state) => const AuthScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/set-password',
|
||||
name: Routes.setPassword,
|
||||
builder: (context, state) => const SetPasswordScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding',
|
||||
name: Routes.onboarding,
|
||||
builder: (context, state) => BlocProvider(
|
||||
create: (context) => OnboardingCubit(
|
||||
GetIt.I.get<SessionCubit>(),
|
||||
@@ -81,79 +120,360 @@ class AppRouter {
|
||||
),
|
||||
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: '/',
|
||||
builder: (context, state) => const HomeScreen(), // La tua home
|
||||
|
||||
// --- CORE APP (DENTRO LA SHELL CON NAVIGATION BAR/RAIL) ---
|
||||
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(
|
||||
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) {
|
||||
// Recuperiamo l'oggetto customer passato tramite extra
|
||||
final customer = state.extra as CustomerModel;
|
||||
return BlocProvider(
|
||||
create: (context) => CustomerFilesBloc(customer.id!),
|
||||
create: (context) => AttachmentsBloc(
|
||||
parentType: AttachmentParentType.customer,
|
||||
parentId: customer.id,
|
||||
),
|
||||
child: CustomerDetailScreen(customer: customer),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/customer/:id/upload',
|
||||
path: '/customer/form/:id',
|
||||
name: Routes.customerForm,
|
||||
builder: (context, state) {
|
||||
final customerId = state.pathParameters['id']!;
|
||||
// Recuperiamo il nome dalle query se vogliamo mostrarlo nel titolo,
|
||||
// oppure lo caricherà il bloc.
|
||||
final customerName = state.uri.queryParameters['name'] ?? 'Cliente';
|
||||
final String pathId = state.pathParameters['id'] ?? 'new';
|
||||
final String? realCustomerId;
|
||||
if (pathId == 'new') {
|
||||
realCustomerId = null;
|
||||
} else {
|
||||
realCustomerId = pathId;
|
||||
}
|
||||
final customer = state.extra as CustomerModel?;
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) => CustomerFilesBloc(customerId),
|
||||
child: CustomerMobileUploadScreen(
|
||||
customerId: customerId,
|
||||
customerName: customerName,
|
||||
create: (context) => CustomerFormCubit(
|
||||
existingCustomer: customer,
|
||||
customerId: realCustomerId,
|
||||
),
|
||||
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(
|
||||
path: '/products',
|
||||
name: 'products',
|
||||
builder: (context, state) => const ProductsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/service-form',
|
||||
name: 'service-form',
|
||||
path: '/notes/edit/:id',
|
||||
name: Routes.noteForm,
|
||||
builder: (context, state) {
|
||||
// Recuperiamo l'oggetto se passato tramite 'extra'
|
||||
final existingService = state.extra as ServiceModel?;
|
||||
// Recuperiamo l'ID se presente nell'URL
|
||||
final serviceId = state.uri.queryParameters['serviceId'];
|
||||
final id = state.pathParameters['id']!;
|
||||
final NoteModel note = state.extra as NoteModel;
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
ServiceFilesBloc(serviceId: serviceId ?? existingService?.id),
|
||||
child: ServiceFormScreen(
|
||||
serviceId: serviceId ?? existingService?.id,
|
||||
existingService: existingService,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/service/:id/upload',
|
||||
builder: (context, state) {
|
||||
final serviceId = state.pathParameters['id']!;
|
||||
final serviceName = state.uri.queryParameters['name'] ?? 'Pratica';
|
||||
// Creiamo il BLoC "al volo" solo per questa schermata
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<AttachmentsBloc>(
|
||||
create: (context) => AttachmentsBloc(
|
||||
parentId: id,
|
||||
parentType: AttachmentParentType.note,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
return BlocProvider(
|
||||
// Inizializziamo il bloc col serviceId corretto!
|
||||
create: (context) => ServiceFilesBloc(serviceId: serviceId),
|
||||
child: ServiceMobileUploadScreen(
|
||||
serviceId: serviceId,
|
||||
serviceName: serviceName,
|
||||
),
|
||||
child: NoteFormScreen(note: note),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -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 {
|
||||
GoRouterRefreshStream(Stream<dynamic> stream) {
|
||||
notifyListeners();
|
||||
@@ -171,9 +489,7 @@ class GoRouterRefreshStream extends ChangeNotifier {
|
||||
(dynamic _) => notifyListeners(),
|
||||
);
|
||||
}
|
||||
|
||||
late final StreamSubscription<dynamic> _subscription;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
|
||||
27
lib/core/routes/routes.dart
Normal 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';
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.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:shared_preferences/shared_preferences.dart';
|
||||
|
||||
|
||||
20
lib/core/utils/app_message.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/core/utils/debouncer.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/l10n/app_localizations.dart';
|
||||
|
||||
extension MyStringExtensions on String? {
|
||||
// Gestiamo anche il nullable per sicurezza
|
||||
String myFormat() {
|
||||
@@ -40,3 +43,7 @@ extension MyStringExtensions on String? {
|
||||
.join('.'); // Ritorna tutto tranne l'ultima parte
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalizationExtension on BuildContext {
|
||||
AppLocalizations get l10n => AppLocalizations.of(this)!;
|
||||
}
|
||||
67
lib/core/utils/version_check_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ class FluxTextField extends StatefulWidget {
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final TextCapitalization? textCapitalization;
|
||||
final bool? autocorrect;
|
||||
final bool? enabled;
|
||||
|
||||
const FluxTextField({
|
||||
super.key, // Usiamo super.key per Flutter moderno
|
||||
@@ -39,6 +40,7 @@ class FluxTextField extends StatefulWidget {
|
||||
this.inputFormatters,
|
||||
this.textCapitalization,
|
||||
this.autocorrect,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -115,6 +117,7 @@ class _FluxTextFieldState extends State<FluxTextField> {
|
||||
inputFormatters: widget.inputFormatters,
|
||||
|
||||
textCapitalization: widget.textCapitalization ?? TextCapitalization.none,
|
||||
enabled: widget.enabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
60
lib/core/widgets/image_upload/blocs/image_upload_cubit.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
lib/core/widgets/image_upload/blocs/image_upload_state.dart
Normal 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];
|
||||
}
|
||||
306
lib/core/widgets/image_upload/ui/image_upload_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,32 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_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 {
|
||||
final String customerId;
|
||||
final String customerName;
|
||||
class OldSharedUploadScreen extends StatefulWidget {
|
||||
final String title;
|
||||
final String companyId;
|
||||
|
||||
const CustomerMobileUploadScreen({
|
||||
const OldSharedUploadScreen({
|
||||
super.key,
|
||||
required this.customerId,
|
||||
required this.customerName,
|
||||
required this.title,
|
||||
required this.companyId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CustomerMobileUploadScreen> createState() =>
|
||||
_CustomerMobileUploadScreenState();
|
||||
State<OldSharedUploadScreen> createState() => _OldSharedUploadScreenState();
|
||||
}
|
||||
|
||||
class _CustomerMobileUploadScreenState
|
||||
extends State<CustomerMobileUploadScreen> {
|
||||
class _OldSharedUploadScreenState extends State<OldSharedUploadScreen> {
|
||||
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
|
||||
final List<PlatformFile> _stagedFiles = [];
|
||||
|
||||
// 2. STATO DI CARICAMENTO GLOBALE
|
||||
bool _isUploading = false;
|
||||
bool _isProcessingLocal = false;
|
||||
|
||||
// Funzione magica per capire se è un'immagine o un PDF dall'estensione
|
||||
bool _isImage(String path) {
|
||||
@@ -36,18 +36,25 @@ class _CustomerMobileUploadScreenState
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<CustomerFilesBloc, CustomerFilesState>(
|
||||
return BlocListener<AttachmentsBloc, AttachmentsState>(
|
||||
listener: (context, state) {
|
||||
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
|
||||
if (state.status == CustomerFilesStatus.success && _isUploading) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Tutti i file caricati con successo! ✅"),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
if (state.status == AttachmentsStatus.success && _isUploading) {
|
||||
// CONTROLLO MAGICO: C'è una pagina dietro di noi?
|
||||
if (Navigator.of(context).canPop()) {
|
||||
// Modalità "App Nativa": siamo entrati dal tasto "Aggiungi"
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("File caricati con successo! ✅")),
|
||||
);
|
||||
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);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
@@ -56,8 +63,8 @@ class _CustomerMobileUploadScreenState
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("Upload: ${widget.customerName}"),
|
||||
// Togliamo la freccia indietro se stiamo caricando per evitare disastri
|
||||
title: Text("Upload: ${widget.title}"),
|
||||
// Togliamo la freccia indietro se stiamo caricando per evitare macelli
|
||||
automaticallyImplyLeading: !_isUploading,
|
||||
),
|
||||
body: Stack(
|
||||
@@ -110,8 +117,7 @@ class _CustomerMobileUploadScreenState
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount:
|
||||
3, // 3 colonne come la galleria dell'iPhone
|
||||
crossAxisCount: 3, // 3 colonne stile galleria
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
@@ -137,10 +143,17 @@ class _CustomerMobileUploadScreenState
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: isImg
|
||||
? Image.file(
|
||||
File(file.path!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
? (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
|
||||
: Image.file(
|
||||
File(file.path!),
|
||||
fit: BoxFit.cover,
|
||||
))
|
||||
: const Column(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
@@ -228,11 +241,11 @@ class _CustomerMobileUploadScreenState
|
||||
],
|
||||
),
|
||||
|
||||
// --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) ---
|
||||
if (_isUploading)
|
||||
// --- OVERLAY DI CARICAMENTO ---
|
||||
if (_isUploading || _isProcessingLocal)
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
@@ -242,7 +255,9 @@ class _CustomerMobileUploadScreenState
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
"Caricamento in corso...",
|
||||
_isUploading
|
||||
? "Invio in corso..."
|
||||
: "Elaborazione foto...",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
@@ -259,30 +274,38 @@ class _CustomerMobileUploadScreenState
|
||||
|
||||
// --- LOGICA FOTOCAMERA E LIBRERIA ---
|
||||
Future<void> _handleCamera() async {
|
||||
final picker = ImagePicker();
|
||||
final photo = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 80,
|
||||
);
|
||||
if (photo != null) {
|
||||
final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web!
|
||||
final photoSize = await photo.length();
|
||||
setState(() => _isProcessingLocal = true);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final platformFile = PlatformFile(
|
||||
name: photo.name,
|
||||
size: photoSize,
|
||||
path: photo.path,
|
||||
bytes: photoBytes, // I bytes ci salvano la vita su Supabase!
|
||||
);
|
||||
setState(() {
|
||||
_stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File
|
||||
});
|
||||
try {
|
||||
final picker = ImagePicker();
|
||||
final photo = await picker.pickImage(source: ImageSource.camera);
|
||||
|
||||
if (photo != null) {
|
||||
final photoBytes = await photo.readAsBytes();
|
||||
final photoSize = await photo.length();
|
||||
|
||||
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 {
|
||||
// allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo!
|
||||
final result = await FilePicker.pickFiles(allowMultiple: true);
|
||||
final result = await FilePicker.pickFiles(
|
||||
allowMultiple: true,
|
||||
withData: true,
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
_stagedFiles.addAll(result.files);
|
||||
@@ -294,11 +317,12 @@ class _CustomerMobileUploadScreenState
|
||||
void _submitAllFiles() {
|
||||
setState(() => _isUploading = true);
|
||||
|
||||
// Diciamo al BLoC di caricare tutti i file.
|
||||
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
|
||||
final bloc = context.read<CustomerFilesBloc>();
|
||||
bloc.add(UploadMultipleCustomerFilesEvent(_stagedFiles));
|
||||
|
||||
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
|
||||
// Lanciamo l'evento del nostro nuovo AttachmentsBloc Agnostico!
|
||||
context.read<AttachmentsBloc>().add(
|
||||
UploadAttachmentsEvent(
|
||||
pickedFiles: _stagedFiles,
|
||||
companyId: widget.companyId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
lib/core/widgets/image_upload/ui/upload_success_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/core/utils/functions.dart';
|
||||
|
||||
class ImageViewerWidget extends StatelessWidget {
|
||||
@@ -36,8 +37,8 @@ class ImageViewerWidget extends StatelessWidget {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return const Text(
|
||||
"Errore caricamento immagine (Permessi negati?)",
|
||||
return Text(
|
||||
context.l10n.imageViewerWidgetErrorOpening,
|
||||
style: TextStyle(color: Colors.red),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/core/utils/functions.dart';
|
||||
import 'package:pdfx/pdfx.dart';
|
||||
import 'package:internet_file/internet_file.dart';
|
||||
@@ -74,13 +75,13 @@ class _PdfViewerWidgetState extends State<PdfViewerWidget> {
|
||||
if (_errorMessage != null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(leading: const CloseButton()),
|
||||
body: Center(child: Text("Errore: $_errorMessage")),
|
||||
body: Center(child: Text(context.l10n.commonError(_errorMessage!))),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Anteprima PDF"),
|
||||
title: Text(context.l10n.pdfViewerAnteprimaPdf),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.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';
|
||||
|
||||
class QrUploadDialog extends StatelessWidget {
|
||||
@@ -16,78 +19,84 @@ class QrUploadDialog extends StatelessWidget {
|
||||
// Usiamo i colori del tema per renderlo coerente col tuo design
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
title: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.qr_code_scanner,
|
||||
size: 48,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
height: 400,
|
||||
width: 350,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min, // Fondamentale per i dialog
|
||||
children: [
|
||||
const Text(
|
||||
"Inquadra questo codice con la fotocamera del tuo telefono per scattare e caricare i documenti direttamente qui.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// IL CUORE DELLA MAGIA
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors
|
||||
.white, // Lo sfondo bianco salva la vita sui temi scuri
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
return BlocListener<AttachmentsBloc, AttachmentsState>(
|
||||
listener: (context, state) {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
listenWhen: (previous, current) =>
|
||||
previous.allFiles.length < current.allFiles.length,
|
||||
child: AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
title: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.qr_code_scanner,
|
||||
size: 48,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
height: 400,
|
||||
width: 350,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min, // Fondamentale per i dialog
|
||||
children: [
|
||||
const Text(
|
||||
"Inquadra questo codice con la fotocamera del tuo telefono per scattare e caricare i documenti direttamente qui.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
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: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors
|
||||
.white, // Lo sfondo bianco salva la vita sui temi scuri
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
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),
|
||||
Text(
|
||||
"In attesa di file...",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"In attesa di file...",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const LinearProgressIndicator(), // Per far capire che è "in ascolto"
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
128
lib/core/widgets/set_password_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
838
lib/core/widgets/shared_forms/attachments_section.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
361
lib/core/widgets/shared_forms/customer_section.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
172
lib/core/widgets/shared_forms/model_section.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
246
lib/core/widgets/shared_forms/shared_files_section.dart
Normal 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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
156
lib/core/widgets/shared_forms/staff_section.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
147
lib/core/widgets/staff_selector_modal.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
414
lib/features/attachments/blocs/attachments_bloc.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
73
lib/features/attachments/blocs/attachments_events.dart
Normal 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);
|
||||
}
|
||||
68
lib/features/attachments/blocs/attachments_state.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
244
lib/features/attachments/data/attachments_repository.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
132
lib/features/attachments/models/attachment_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
220
lib/features/attachments/ui/attachment_viewer_screen.dart
Normal 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!)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
lib/features/attachments/ui/quick_rename_dialog.dart
Normal 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)'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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:supabase_flutter/supabase_flutter.dart';
|
||||
part 'auth_state.dart';
|
||||
@@ -40,7 +42,9 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AuthStatus.initial,
|
||||
infoMessage: "Controlla la tua email per confermare l'account!",
|
||||
infoMessage: AppMessage(
|
||||
key: 'authCubitCheckEmailToConfirmAccount',
|
||||
),
|
||||
),
|
||||
);
|
||||
} 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 {
|
||||
await _supabase.auth.signOut();
|
||||
emit(state.copyWith(status: AuthStatus.initial));
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
part of 'auth_cubit.dart';
|
||||
|
||||
enum AuthStatus { initial, loading, failure }
|
||||
enum AuthStatus { initial, pwResetSent, loading, failure }
|
||||
|
||||
class AuthState extends Equatable {
|
||||
final AuthStatus status;
|
||||
final bool isLoginMode;
|
||||
final String? errorMessage;
|
||||
final String? infoMessage;
|
||||
final AppMessage? infoMessage;
|
||||
|
||||
const AuthState({
|
||||
this.status = AuthStatus.initial,
|
||||
@@ -19,7 +19,7 @@ class AuthState extends Equatable {
|
||||
AuthStatus? status,
|
||||
bool? isLoginMode,
|
||||
String? errorMessage,
|
||||
String? infoMessage,
|
||||
AppMessage? infoMessage,
|
||||
}) {
|
||||
return AuthState(
|
||||
status: status ?? this.status,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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_text_field.dart';
|
||||
import 'package:flux/features/auth/bloc/auth_cubit.dart';
|
||||
@@ -55,7 +56,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
if (state.infoMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.infoMessage!),
|
||||
content: Text(state.infoMessage!.translatedMessage(context)),
|
||||
backgroundColor: Colors.blueAccent, // O context.accent
|
||||
),
|
||||
);
|
||||
@@ -77,7 +78,9 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
|
||||
// --- TITOLO DINAMICO ---
|
||||
Text(
|
||||
state.isLoginMode ? 'BENTORNATO' : 'CREA ACCOUNT',
|
||||
state.isLoginMode
|
||||
? context.l10n.authScreenWelcomeBack
|
||||
: context.l10n.authScreenCreateAccount,
|
||||
style: TextStyle(
|
||||
color: context.primaryText,
|
||||
fontSize: 24,
|
||||
@@ -88,8 +91,10 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.isLoginMode
|
||||
? 'Accedi per gestire il tuo business'
|
||||
: 'Inizia oggi a digitalizzare il tuo negozio',
|
||||
? context.l10n.authScreenLoginToManageYourBusiness
|
||||
: context
|
||||
.l10n
|
||||
.authScreenStartTodayToDigitalizeYourStore,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: context.secondaryText),
|
||||
),
|
||||
@@ -97,7 +102,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
|
||||
// --- CAMPI INPUT ---
|
||||
FluxTextField(
|
||||
label: 'Email Aziendale',
|
||||
label: context.l10n.authScreenBusinessEmail,
|
||||
icon: Icons.email_outlined,
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
@@ -130,7 +135,9 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
state.isLoginMode ? 'ACCEDI' : 'REGISTRATI',
|
||||
state.isLoginMode
|
||||
? context.l10n.authScreenLogin
|
||||
: context.l10n.authScreenSignUp,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -147,12 +154,15 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: state.isLoginMode
|
||||
? "Non hai un account? "
|
||||
: "Hai già un account? ",
|
||||
? context.l10n.authScreenDontHaveAccount
|
||||
: context.l10n.authScreenAlreadyHaveAccount,
|
||||
style: TextStyle(color: context.secondaryText),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: state.isLoginMode ? "Registrati" : "Accedi",
|
||||
text: state.isLoginMode
|
||||
? context.l10n.authScreenSignUp
|
||||
: context.l10n.authScreenLogin,
|
||||
|
||||
style: TextStyle(
|
||||
color: context.accent,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
131
lib/features/company/bloc/company_settings_cubit.dart
Normal 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",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
37
lib/features/company/bloc/company_settings_state.dart
Normal 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,
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 '../models/company_model.dart';
|
||||
|
||||
@@ -8,7 +11,7 @@ class CompanyRepository {
|
||||
try {
|
||||
// .select().single() trasforma la risposta nell'oggetto appena inserito
|
||||
final response = await _supabase
|
||||
.from('company')
|
||||
.from(Tables.companies)
|
||||
.insert(company.toMap())
|
||||
.select()
|
||||
.single();
|
||||
@@ -17,7 +20,63 @@ class CompanyRepository {
|
||||
} on PostgrestException catch (e) {
|
||||
throw e.message;
|
||||
} 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 {
|
||||
final userId = _supabase.auth.currentUser?.id;
|
||||
final response = await _supabase
|
||||
.from('company')
|
||||
.from(Tables.companies)
|
||||
.select()
|
||||
.eq('user_id', userId as Object)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -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
|
||||
// ===================================================================
|
||||
@@ -45,16 +60,22 @@ class CompanyModel extends Equatable {
|
||||
final String userId; // Nel DB è user_id (chiave esterna su auth.users)
|
||||
|
||||
// Dati Anagrafici e Fatturazione
|
||||
final String ragioneSociale;
|
||||
final String indirizzo;
|
||||
final String cap;
|
||||
final String citta;
|
||||
final String provincia;
|
||||
final String partitaIva;
|
||||
final String codiceFiscale;
|
||||
final String codiceUnivoco;
|
||||
final String companyLogo;
|
||||
|
||||
final String name;
|
||||
final String address;
|
||||
final String zipCode;
|
||||
final String city;
|
||||
final String province;
|
||||
final String vatId;
|
||||
final String fiscalCode;
|
||||
final String sdi;
|
||||
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)
|
||||
final bool isPaid;
|
||||
final DateTime? paymentExpiration;
|
||||
@@ -70,15 +91,22 @@ class CompanyModel extends Equatable {
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.userId,
|
||||
required this.ragioneSociale,
|
||||
required this.indirizzo,
|
||||
required this.cap,
|
||||
required this.citta,
|
||||
required this.provincia,
|
||||
required this.partitaIva,
|
||||
required this.codiceFiscale,
|
||||
required this.codiceUnivoco,
|
||||
this.companyLogo = '',
|
||||
required this.name,
|
||||
required this.address,
|
||||
required this.zipCode,
|
||||
required this.city,
|
||||
required this.province,
|
||||
required this.vatId,
|
||||
required this.fiscalCode,
|
||||
required this.sdi,
|
||||
this.phone,
|
||||
this.email,
|
||||
this.logoUrl,
|
||||
this.ticketDisclaimer,
|
||||
this.labelFormat = LabelFormat.none,
|
||||
this.labelWidth,
|
||||
this.labelHeight,
|
||||
this.isLabelVertical = false,
|
||||
this.isPaid = false,
|
||||
this.paymentExpiration,
|
||||
this.subscriptionTier = SubscriptionTier.free,
|
||||
@@ -92,15 +120,22 @@ class CompanyModel extends Equatable {
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? userId,
|
||||
String? ragioneSociale,
|
||||
String? indirizzo,
|
||||
String? cap,
|
||||
String? citta,
|
||||
String? provincia,
|
||||
String? partitaIva,
|
||||
String? codiceFiscale,
|
||||
String? codiceUnivoco,
|
||||
String? companyLogo,
|
||||
String? name,
|
||||
String? address,
|
||||
String? zipCode,
|
||||
String? city,
|
||||
String? province,
|
||||
String? vatId,
|
||||
String? fiscalCode,
|
||||
String? sdi,
|
||||
String? logoUrl,
|
||||
String? ticketDisclaimer,
|
||||
LabelFormat? labelFormat,
|
||||
double? labelWidth,
|
||||
double? labelHeight,
|
||||
bool? isLabelVertical,
|
||||
String? phone,
|
||||
String? email,
|
||||
bool? isPaid,
|
||||
DateTime? paymentExpiration,
|
||||
SubscriptionTier? subscriptionTier,
|
||||
@@ -113,15 +148,22 @@ class CompanyModel extends Equatable {
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
userId: userId ?? this.userId,
|
||||
ragioneSociale: ragioneSociale ?? this.ragioneSociale,
|
||||
indirizzo: indirizzo ?? this.indirizzo,
|
||||
cap: cap ?? this.cap,
|
||||
citta: citta ?? this.citta,
|
||||
provincia: provincia ?? this.provincia,
|
||||
partitaIva: partitaIva ?? this.partitaIva,
|
||||
codiceFiscale: codiceFiscale ?? this.codiceFiscale,
|
||||
codiceUnivoco: codiceUnivoco ?? this.codiceUnivoco,
|
||||
companyLogo: companyLogo ?? this.companyLogo,
|
||||
name: name ?? this.name,
|
||||
address: address ?? this.address,
|
||||
zipCode: zipCode ?? this.zipCode,
|
||||
city: city ?? this.city,
|
||||
province: province ?? this.province,
|
||||
vatId: vatId ?? this.vatId,
|
||||
fiscalCode: fiscalCode ?? this.fiscalCode,
|
||||
sdi: sdi ?? this.sdi,
|
||||
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,
|
||||
paymentExpiration: paymentExpiration ?? this.paymentExpiration,
|
||||
subscriptionTier: subscriptionTier ?? this.subscriptionTier,
|
||||
@@ -137,14 +179,14 @@ class CompanyModel extends Equatable {
|
||||
id: null,
|
||||
createdAt: null,
|
||||
userId: '',
|
||||
ragioneSociale: '',
|
||||
indirizzo: '',
|
||||
cap: '',
|
||||
citta: '',
|
||||
provincia: '',
|
||||
partitaIva: '',
|
||||
codiceFiscale: '',
|
||||
codiceUnivoco: '',
|
||||
name: '',
|
||||
address: '',
|
||||
zipCode: '',
|
||||
city: '',
|
||||
province: '',
|
||||
vatId: '',
|
||||
fiscalCode: '',
|
||||
sdi: '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,15 +197,26 @@ class CompanyModel extends Equatable {
|
||||
? DateTime.tryParse(map['created_at'])
|
||||
: null,
|
||||
userId: map['user_id'] ?? '',
|
||||
ragioneSociale: map['ragione_sociale'] ?? '',
|
||||
indirizzo: map['indirizzo'] ?? '',
|
||||
cap: map['cap'] ?? '',
|
||||
citta: map['citta'] ?? '',
|
||||
provincia: map['provincia'] ?? '',
|
||||
partitaIva: map['partita_iva'] ?? '',
|
||||
codiceFiscale: map['codice_fiscale'] ?? '',
|
||||
codiceUnivoco: map['codice_univoco'] ?? '',
|
||||
companyLogo: map['company_logo'] ?? '',
|
||||
name: map['name'] ?? '',
|
||||
address: map['address'] ?? '',
|
||||
zipCode: map['zip_code'] ?? '',
|
||||
city: map['city'] ?? '',
|
||||
province: map['province'] ?? '',
|
||||
vatId: map['vat_id'] ?? '',
|
||||
fiscalCode: map['fiscal_code'] ?? '',
|
||||
sdi: map['sdi'] ?? '',
|
||||
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,
|
||||
paymentExpiration: map['payment_expiration'] != null
|
||||
? DateTime.tryParse(map['payment_expiration'])
|
||||
@@ -185,15 +238,22 @@ class CompanyModel extends Equatable {
|
||||
if (id != null) 'id': id,
|
||||
// created_at è gestito dal DB di default, di solito non si passa nell'insert
|
||||
'user_id': userId,
|
||||
'ragione_sociale': ragioneSociale,
|
||||
'indirizzo': indirizzo,
|
||||
'cap': cap,
|
||||
'citta': citta,
|
||||
'provincia': provincia,
|
||||
'partita_iva': partitaIva,
|
||||
'codice_fiscale': codiceFiscale,
|
||||
'codice_univoco': codiceUnivoco,
|
||||
'company_logo': companyLogo,
|
||||
'name': name,
|
||||
'address': address,
|
||||
'zip_code': zipCode,
|
||||
'city': city,
|
||||
'province': province,
|
||||
'vat_id': vatId,
|
||||
'fiscal_code': fiscalCode,
|
||||
'sdi': sdi,
|
||||
'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,
|
||||
if (paymentExpiration != null)
|
||||
'payment_expiration': paymentExpiration!.toIso8601String(),
|
||||
@@ -213,15 +273,22 @@ class CompanyModel extends Equatable {
|
||||
id,
|
||||
createdAt,
|
||||
userId,
|
||||
ragioneSociale,
|
||||
indirizzo,
|
||||
cap,
|
||||
citta,
|
||||
provincia,
|
||||
partitaIva,
|
||||
codiceFiscale,
|
||||
codiceUnivoco,
|
||||
companyLogo,
|
||||
name,
|
||||
address,
|
||||
zipCode,
|
||||
city,
|
||||
province,
|
||||
vatId,
|
||||
fiscalCode,
|
||||
sdi,
|
||||
logoUrl,
|
||||
phone,
|
||||
email,
|
||||
ticketDisclaimer,
|
||||
labelFormat,
|
||||
labelWidth,
|
||||
labelHeight,
|
||||
isLabelVertical,
|
||||
isPaid,
|
||||
paymentExpiration,
|
||||
subscriptionTier,
|
||||
@@ -263,7 +330,7 @@ extension CompanyLimits on CompanyModel {
|
||||
}
|
||||
}
|
||||
|
||||
int get maxServicesPerMonth {
|
||||
int get maxOperationsPerMonth {
|
||||
switch (subscriptionTier) {
|
||||
case SubscriptionTier.free:
|
||||
return 50;
|
||||
|
||||
452
lib/features/company/ui/company_settings_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
132
lib/features/customers/blocs/customer_form_cubit.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
lib/features/customers/blocs/customer_form_state.dart
Normal 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];
|
||||
}
|
||||
85
lib/features/customers/blocs/customers_list_cubit.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
part of 'customer_cubit.dart';
|
||||
part of 'customers_list_cubit.dart';
|
||||
|
||||
enum CustomerStatus {
|
||||
enum CustomersListStatus {
|
||||
initial,
|
||||
loading,
|
||||
filesLoading,
|
||||
@@ -9,34 +9,30 @@ enum CustomerStatus {
|
||||
failure,
|
||||
}
|
||||
|
||||
class CustomerState extends Equatable {
|
||||
final CustomerStatus status;
|
||||
class CustomersListState extends Equatable {
|
||||
final CustomersListStatus status;
|
||||
final List<CustomerModel> customers;
|
||||
final CustomerModel? lastCreatedCustomer;
|
||||
final String? errorMessage;
|
||||
final List<CustomerFileModel> customerFiles;
|
||||
|
||||
const CustomerState({
|
||||
this.status = CustomerStatus.initial,
|
||||
const CustomersListState({
|
||||
this.status = CustomersListStatus.initial,
|
||||
this.customers = const [],
|
||||
this.lastCreatedCustomer,
|
||||
this.errorMessage,
|
||||
this.customerFiles = const [],
|
||||
});
|
||||
|
||||
CustomerState copyWith({
|
||||
CustomerStatus? status,
|
||||
CustomersListState copyWith({
|
||||
CustomersListStatus? status,
|
||||
List<CustomerModel>? customers,
|
||||
CustomerModel? lastCreatedCustomer,
|
||||
String? errorMessage,
|
||||
List<CustomerFileModel>? customerFiles,
|
||||
}) {
|
||||
return CustomerState(
|
||||
return CustomersListState(
|
||||
status: status ?? this.status,
|
||||
customers: customers ?? this.customers,
|
||||
lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
customerFiles: customerFiles ?? this.customerFiles,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +42,5 @@ class CustomerState extends Equatable {
|
||||
customers,
|
||||
lastCreatedCustomer,
|
||||
errorMessage,
|
||||
customerFiles,
|
||||
];
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
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/utils/string_extensions.dart';
|
||||
import 'package:flux/features/customers/models/customer_file_model.dart';
|
||||
import 'package:flux/core/enums_and_consts/consts.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:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/customer_model.dart';
|
||||
@@ -12,30 +12,30 @@ class CustomerRepository {
|
||||
final String companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||
|
||||
// Crea un nuovo cliente
|
||||
Future<CustomerModel> saveCustomer(CustomerModel customer) async {
|
||||
Future<CustomerModel> insertCustomer(CustomerModel customer) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('customer')
|
||||
.from(Tables.customers)
|
||||
.upsert(customer.toJson())
|
||||
.select()
|
||||
.single();
|
||||
return CustomerModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw 'Errore durante il salvataggio del cliente: $e';
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<CustomerModel> updateCustomer(CustomerModel customer) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('customer')
|
||||
.from(Tables.customers)
|
||||
.update(customer.toJson())
|
||||
.eq('id', customer.id!)
|
||||
.select()
|
||||
.single();
|
||||
return CustomerModel.fromMap(response);
|
||||
} 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 {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('customer')
|
||||
.from(Tables.customers)
|
||||
.select('''
|
||||
*,
|
||||
customer_file(*)
|
||||
${Tables.attachments}(*)
|
||||
''')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('nome');
|
||||
.order('name', ascending: true);
|
||||
|
||||
return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
|
||||
} 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 {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('customer')
|
||||
.from(Tables.customers)
|
||||
.select()
|
||||
.eq('company_id', companyId)
|
||||
.or('nome.ilike.%$query%,telefono.ilike.%$query%')
|
||||
.or('name.ilike.%$query%,phone_number.ilike.%$query%')
|
||||
.limit(10);
|
||||
|
||||
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
|
||||
Stream<List<CustomerFileModel>> getCustomerFilesStream(String customerId) {
|
||||
Stream<List<AttachmentModel>> getCustomerFilesStream(String customerId) {
|
||||
return _supabase
|
||||
.from('customer_file')
|
||||
.from(Tables.attachments)
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('customer_id', customerId)
|
||||
.order('created_at', ascending: false)
|
||||
.map(
|
||||
(listOfMaps) =>
|
||||
listOfMaps.map((map) => CustomerFileModel.fromMap(map)).toList(),
|
||||
listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Recupera i file di un cliente specifico
|
||||
Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async {
|
||||
Future<List<AttachmentModel>> getCustomerFiles(String customerId) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('customer_file')
|
||||
.from(Tables.attachments)
|
||||
.select()
|
||||
.eq('customer_id', customerId);
|
||||
|
||||
return (response as List)
|
||||
.map((f) => CustomerFileModel.fromMap(f))
|
||||
.toList();
|
||||
return (response as List).map((f) => AttachmentModel.fromMap(f)).toList();
|
||||
} catch (e) {
|
||||
throw 'Errore recupero file: $e';
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
/// Carica un file e salva il riferimento nel database
|
||||
Future<CustomerFileModel> uploadAndRegisterFile({
|
||||
Future<AttachmentModel> uploadAndRegisterFile({
|
||||
required String customerId,
|
||||
required PlatformFile pickedFile,
|
||||
}) async {
|
||||
@@ -118,7 +133,8 @@ class CustomerRepository {
|
||||
final storagePath =
|
||||
'$companyId/customers/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
|
||||
final int fileSize = pickedFile.size;
|
||||
final fileToSave = CustomerFileModel(
|
||||
final fileToSave = AttachmentModel(
|
||||
companyId: companyId,
|
||||
customerId: customerId,
|
||||
name: cleanFileName.fileNameWithoutExtension(),
|
||||
extension: cleanFileName.fileExtension(),
|
||||
@@ -131,7 +147,7 @@ class CustomerRepository {
|
||||
try {
|
||||
// Usiamo bytes invece del path per massima compatibilità
|
||||
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
|
||||
@@ -146,54 +162,54 @@ class CustomerRepository {
|
||||
}
|
||||
|
||||
final response = await _supabase
|
||||
.from('customer_file')
|
||||
.from(Tables.attachments)
|
||||
.insert(fileToSave.toMap())
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return CustomerFileModel.fromMap(response);
|
||||
return AttachmentModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw 'Errore durante l\'upload: $e';
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveFileReference(CustomerFileModel file) async {
|
||||
await _supabase.from('customer_file').upsert(file.toMap());
|
||||
Future<void> saveFileReference(AttachmentModel file) async {
|
||||
await _supabase.from(Tables.attachments).upsert(file.toMap());
|
||||
}
|
||||
|
||||
/// Aggiorna la lista degli URL nel database
|
||||
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 {
|
||||
Future<void> deleteDocuments(List<AttachmentModel> files) async {
|
||||
if (files.isEmpty) return;
|
||||
|
||||
// 1. Prepariamo le liste di ID e di Percorsi
|
||||
final List<String> idsToDelete = files.map((f) => f.id!).toList();
|
||||
final List<String> storagePaths = files.map((f) => f.storagePath).toList();
|
||||
|
||||
final List<String> idsToDelete = [];
|
||||
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 {
|
||||
// 2. Cancellazione MASSIVA dal DB (una sola chiamata invece di un ciclo!)
|
||||
// .in_ dice: "cancella tutti i record il cui ID è contenuto in questa lista"
|
||||
await _supabase
|
||||
.from('customer_file')
|
||||
.delete()
|
||||
.inFilter('id', idsToDelete);
|
||||
|
||||
// 3. Cancellazione MASSIVA dallo Storage
|
||||
await _supabase.storage.from('documents').remove(storagePaths);
|
||||
|
||||
debugPrint("Eliminati con successo ${files.length} file.");
|
||||
if (idsToDelete.isNotEmpty) {
|
||||
await _supabase
|
||||
.from(Tables.attachments)
|
||||
.delete()
|
||||
.inFilter('id', idsToDelete);
|
||||
// 3. Cancellazione MASSIVA dallo Storage
|
||||
await _supabase.storage.from('documents').remove(storagePathsToDelete);
|
||||
}
|
||||
if (idsToEdit.isNotEmpty) {
|
||||
await _supabase
|
||||
.from(Tables.attachments)
|
||||
.update({'customer_id': null})
|
||||
.inFilter('id', idsToEdit);
|
||||
}
|
||||
} on PostgrestException catch (e) {
|
||||
debugPrint("Errore DB: ${e.message}");
|
||||
throw 'Errore database: ${e.message}';
|
||||
throw e.message;
|
||||
} catch (e) {
|
||||
debugPrint("Errore generico: $e");
|
||||
throw 'Errore durante l\'eliminazione dei file: $e';
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -1,74 +1,88 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/core/utils/string_extensions.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';
|
||||
|
||||
class CustomerModel extends Equatable {
|
||||
final String? id; // Bigint in SQL
|
||||
final DateTime? createdAt;
|
||||
final String nome;
|
||||
final String telefono;
|
||||
final String name;
|
||||
final String phoneNumber;
|
||||
final String email;
|
||||
final String note;
|
||||
final DateTime? dataUltimoContatto;
|
||||
final bool nonDisturbare;
|
||||
final DateTime? lastContactDate;
|
||||
final bool doNotDisturb;
|
||||
final String companyId; // UUID
|
||||
final bool isActive;
|
||||
final List<CustomerFileModel> files;
|
||||
final List<AttachmentModel> attachments;
|
||||
final bool isBusiness;
|
||||
|
||||
const CustomerModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.nome,
|
||||
required this.telefono,
|
||||
required this.name,
|
||||
required this.phoneNumber,
|
||||
required this.email,
|
||||
required this.note,
|
||||
this.dataUltimoContatto,
|
||||
this.nonDisturbare = false,
|
||||
this.lastContactDate,
|
||||
this.doNotDisturb = false,
|
||||
required this.companyId,
|
||||
this.isActive = true,
|
||||
this.files = const [],
|
||||
this.attachments = const [],
|
||||
this.isBusiness = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
nome,
|
||||
telefono,
|
||||
name,
|
||||
phoneNumber,
|
||||
email,
|
||||
note,
|
||||
dataUltimoContatto,
|
||||
nonDisturbare,
|
||||
lastContactDate,
|
||||
doNotDisturb,
|
||||
companyId,
|
||||
isActive,
|
||||
files,
|
||||
attachments,
|
||||
isBusiness,
|
||||
];
|
||||
|
||||
factory CustomerModel.empty() => CustomerModel(
|
||||
name: '',
|
||||
phoneNumber: '',
|
||||
email: '',
|
||||
note: '',
|
||||
companyId:
|
||||
'', // Dovrebbe essere sempre fornito, ma lasciamo vuoto per sicurezza
|
||||
);
|
||||
|
||||
CustomerModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? nome,
|
||||
String? telefono,
|
||||
String? name,
|
||||
String? phoneNumber,
|
||||
String? email,
|
||||
String? note,
|
||||
DateTime? dataUltimoContatto,
|
||||
bool? nonDisturbare,
|
||||
DateTime? lastContactDate,
|
||||
bool? doNotDisturb,
|
||||
String? companyId,
|
||||
bool? isActive,
|
||||
List<CustomerFileModel>? files,
|
||||
List<AttachmentModel>? attachments,
|
||||
bool? isBusiness,
|
||||
}) {
|
||||
return CustomerModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
nome: nome ?? this.nome,
|
||||
telefono: telefono ?? this.telefono,
|
||||
name: name ?? this.name,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
email: email ?? this.email,
|
||||
note: note ?? this.note,
|
||||
dataUltimoContatto: dataUltimoContatto ?? this.dataUltimoContatto,
|
||||
nonDisturbare: nonDisturbare ?? this.nonDisturbare,
|
||||
lastContactDate: lastContactDate ?? this.lastContactDate,
|
||||
doNotDisturb: doNotDisturb ?? this.doNotDisturb,
|
||||
companyId: companyId ?? this.companyId,
|
||||
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
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
nome: (map['nome'] as String).myFormat(),
|
||||
telefono: map['telefono'],
|
||||
name: (map['name'] as String).myFormat(),
|
||||
phoneNumber: map['phone_number'],
|
||||
email: map['email'],
|
||||
note: map['note'] ?? '',
|
||||
dataUltimoContatto: map['data_ultimo_contatto'] != null
|
||||
? DateTime.parse(map['data_ultimo_contatto'])
|
||||
lastContactDate: map['last_contact_date'] != null
|
||||
? DateTime.parse(map['last_contact_date'])
|
||||
: null,
|
||||
nonDisturbare: map['non_disturbare'] ?? false,
|
||||
doNotDisturb: map['do_not_disturb'] ?? false,
|
||||
companyId: map['company_id'] as String,
|
||||
isActive: map['is_active'] ?? true,
|
||||
files:
|
||||
(map['customer_file'] as List?)
|
||||
?.map((x) => CustomerFileModel.fromMap(x))
|
||||
attachments:
|
||||
(map['attachment'] as List?)
|
||||
?.map((x) => AttachmentModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
isBusiness: map['is_business'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'nome': nome.toLowerCase().trim(),
|
||||
'telefono': telefono,
|
||||
'name': name.toLowerCase().trim(),
|
||||
'phone_number': phoneNumber,
|
||||
'email': email.toLowerCase().trim(),
|
||||
'note': note,
|
||||
if (dataUltimoContatto != null)
|
||||
'data_ultimo_contatto': dataUltimoContatto!.toIso8601String(),
|
||||
'non_disturbare': nonDisturbare,
|
||||
if (lastContactDate != null)
|
||||
'last_contact_date': lastContactDate!.toIso8601String(),
|
||||
'do_not_disturb': doNotDisturb,
|
||||
'company_id': companyId,
|
||||
'is_active': isActive,
|
||||
'is_business': isBusiness,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/pdf_viewer_widget.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_file_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
class CustomerDetailScreen extends StatefulWidget {
|
||||
final CustomerModel customer;
|
||||
@@ -26,11 +27,13 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
}
|
||||
|
||||
void _loadFiles() {
|
||||
context.read<CustomerFilesBloc>().add(LoadCustomerFilesEvent());
|
||||
context.read<AttachmentsBloc>().add(
|
||||
LoadAttachmentsEvent(parentId: widget.customer.id),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickAndUpload() async {
|
||||
CustomerFilesBloc customerFilesBloc = context.read<CustomerFilesBloc>();
|
||||
AttachmentsBloc attachmentsBloc = context.read<AttachmentsBloc>();
|
||||
|
||||
// Chiamata statica pulita
|
||||
FilePickerResult? result = await FilePicker.pickFiles(
|
||||
@@ -40,17 +43,18 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
for (var pickedFile in result.files) {
|
||||
try {
|
||||
customerFilesBloc.add(
|
||||
UploadCustomerFileEvent(pickedFile: pickedFile),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Errore upload ${pickedFile.name}: $e")),
|
||||
);
|
||||
}
|
||||
try {
|
||||
attachmentsBloc.add(
|
||||
UploadAttachmentsEvent(
|
||||
pickedFiles: result.files,
|
||||
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text("$e")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +66,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
backgroundColor: context.background,
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.customer.nome,
|
||||
widget.customer.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
backgroundColor: context.background,
|
||||
@@ -103,7 +107,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_infoTile(Icons.phone_android, "Telefono", widget.customer.telefono),
|
||||
_infoTile(Icons.phone_android, "Telefono", widget.customer.phoneNumber),
|
||||
_infoTile(
|
||||
Icons.email_outlined,
|
||||
"Email",
|
||||
@@ -117,7 +121,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
: widget.customer.note,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (widget.customer.nonDisturbare)
|
||||
if (widget.customer.doNotDisturb)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
@@ -143,7 +147,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
}
|
||||
|
||||
Widget _buildDocumentSection() {
|
||||
return BlocBuilder<CustomerFilesBloc, CustomerFilesState>(
|
||||
return BlocBuilder<AttachmentsBloc, AttachmentsState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -191,8 +195,8 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
context: context,
|
||||
builder: (context) => QrUploadDialog(
|
||||
deepLinkUrl:
|
||||
'fluxapp://customer/${widget.customer.id}/upload?name=${Uri.encodeComponent(widget.customer.nome)}',
|
||||
title: 'Scatta per ${widget.customer.nome}',
|
||||
'fluxapp://customer/${widget.customer.id}/upload?name=${Uri.encodeComponent(widget.customer.name)}',
|
||||
title: 'Scatta per ${widget.customer.name}',
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -213,9 +217,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (state.status == CustomerFilesStatus.loading)
|
||||
if (state.status == AttachmentsStatus.loading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (state.customerFiles.isEmpty)
|
||||
else if (state.allFiles.isEmpty)
|
||||
const Center(child: Text("Nessun documento presente"))
|
||||
else
|
||||
Expanded(
|
||||
@@ -226,9 +230,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: 1.2,
|
||||
),
|
||||
itemCount: state.customerFiles.length,
|
||||
itemCount: state.allFiles.length,
|
||||
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({
|
||||
required BuildContext context,
|
||||
required List<CustomerFileModel> files,
|
||||
required List<AttachmentModel> files,
|
||||
}) {}
|
||||
}
|
||||
|
||||
class _FileCard extends StatelessWidget {
|
||||
final CustomerFileModel file;
|
||||
final CustomerFilesState state;
|
||||
final AttachmentModel file;
|
||||
final AttachmentsState state;
|
||||
const _FileCard({required this.file, required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => context.read<CustomerFilesBloc>().add(
|
||||
ToggleCustomerFileSelectionEvent(file),
|
||||
onTap: () => context.read<AttachmentsBloc>().add(
|
||||
ToggleAttachmentSelectionEvent(file),
|
||||
),
|
||||
onDoubleTap: () => _handleDoubleClickOnFile(context, file),
|
||||
child: Stack(
|
||||
@@ -334,7 +338,7 @@ class _FileCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDoubleClickOnFile(BuildContext context, CustomerFileModel file) {
|
||||
void _handleDoubleClickOnFile(BuildContext context, AttachmentModel file) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
225
lib/features/customers/ui/customer_form_screen.dart
Normal 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',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||