Compare commits
123 Commits
2aab70aec5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f8504d466a | |||
| 22bb86f052 | |||
| fc850795c9 | |||
| b60ce96dd7 | |||
| 6582da60d4 | |||
| d42cc5af1d | |||
| 7ea0e2ac10 | |||
| 5ce0110197 | |||
| 4efc3ce182 | |||
| 01515910b6 | |||
| f27ede7625 | |||
| 99ab7abf6e | |||
| a7fd37a894 | |||
| 8ad2b7cf7e | |||
| 3210b4fcfa | |||
| a51ac8fe7f | |||
| 7fad6ee02b | |||
| 6a6e792cd9 | |||
| 3c33c8765a | |||
| 27a5bc16bc | |||
| 808de7b354 | |||
| 618cbc0396 | |||
| 67a56f2954 | |||
| 88b1a618bd | |||
| d989b14967 | |||
| d4ff2b9a7e | |||
| 06ee11521d | |||
| 55d6429dc5 | |||
| 44c85766fc | |||
| b69308e1ef | |||
| 6394e5a2cd | |||
| f31ff19a74 | |||
| 064179a753 | |||
| 727eaac3d9 | |||
| bd81173559 | |||
| 9bace01b93 | |||
| 5ad3e12b1f | |||
| 6211cc6729 | |||
| f15a2aa6e6 | |||
| aed841dc0b | |||
| 221260aca3 | |||
| 83988597d5 | |||
| b298509178 | |||
| b6e5f9acbe | |||
| f6ecb33729 | |||
| 9d796d6e41 | |||
| 45455a16c4 | |||
| 2afe97c6db | |||
| 4101b736e6 | |||
| b67354610d | |||
| b19c91a7dd | |||
| 9b5d19b926 | |||
| aad9a991c2 | |||
| 7f0d18eed1 | |||
| 879c848d77 | |||
| 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 |
115
.gitea/workflows/release.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
name: Build and Release FLUX (Multi-Platform)
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
# -----------------------------------------------------------------
|
||||
# JOB 1: WINDOWS (Gira sul PC del collega appena si libera)
|
||||
# -----------------------------------------------------------------
|
||||
build-windows:
|
||||
runs-on: windows-native
|
||||
steps:
|
||||
- name: Checkout del codice
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Crea file .env
|
||||
run: |
|
||||
Set-Content -Path ".env" -Value "${{ secrets.ENV_FILE_CONTENT }}"
|
||||
|
||||
- name: Build Flutter Windows
|
||||
run: flutter build windows --release
|
||||
|
||||
# 1. FIRMA DELL'ESEGUIBILE RAW
|
||||
- name: Firma Eseguibile Flutter
|
||||
run: |
|
||||
# Cerca dinamicamente signtool.exe nell'SDK di Windows per non sbagliare percorso
|
||||
$Signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1).FullName
|
||||
|
||||
# Sostituisci il percorso con dove hai salvato fisicamente il file .pfx sul PC del runner!
|
||||
& $Signtool sign /f "C:\flux_privato.pfx" /p "${{ secrets.PFX_PASSWORD }}" /fd SHA256 "build\windows\x64\runner\Release\flux.exe"
|
||||
|
||||
- name: Build Windows Installer
|
||||
run: |
|
||||
$TagVersion = "${{ github.ref_name }}".Substring(1)
|
||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "/DMyAppVersion=$TagVersion" "win_installer.iss"
|
||||
|
||||
# 2. FIRMA DELL'INSTALLER GENERATO DA INNO SETUP
|
||||
- name: Firma Installer
|
||||
run: |
|
||||
$Signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1).FullName
|
||||
|
||||
& $Signtool sign /f "C:\flux_privato.pfx" /p "${{ secrets.PFX_PASSWORD }}" /fd SHA256 "build\windows\installer\FluxInstaller.exe"
|
||||
|
||||
# Nel dubbio usiamo l'action per caricare l'asset
|
||||
- name: Upload Windows Asset
|
||||
uses: https://gitea.com/actions/release-action@main
|
||||
with:
|
||||
files: "build/windows/installer/FluxInstaller.exe"
|
||||
api_key: ${{ secrets.MYRELEASE_TOKEN }}
|
||||
|
||||
- name: Pulisci Workspace Windows
|
||||
if: always()
|
||||
run: Remove-Item -Recurse -Force ./*
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# JOB 2: ANDROID APK (Gira sul tuo MacBook)
|
||||
# -----------------------------------------------------------------
|
||||
build-android:
|
||||
runs-on: macos-runner # <--- Etichetta del tuo Mac
|
||||
steps:
|
||||
- name: Checkout del codice
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Logica Bash per Mac: usiamo le virgolette singole forti per evitare escape strani
|
||||
- name: Crea file .env
|
||||
run: |
|
||||
cat << 'EOF' > .env
|
||||
${{ secrets.ENV_FILE_CONTENT }}
|
||||
EOF
|
||||
|
||||
- name: Build Flutter APK
|
||||
run: flutter build apk --release
|
||||
|
||||
# Carichiamo l'APK universale o quelli splittati nelle release di Gitea
|
||||
- name: Upload Android Asset
|
||||
uses: https://gitea.com/actions/release-action@main
|
||||
with:
|
||||
files: "build/app/outputs/flutter-apk/app-release.apk"
|
||||
api_key: ${{ secrets.MYRELEASE_TOKEN }}
|
||||
|
||||
- name: Aggiorna Link Android su Supabase
|
||||
run: |
|
||||
curl -X PATCH "https://pvqpjloswwvtfoxbkfbh.supabase.co/rest/v1/app_config?id=eq.49f18b19-2129-46c0-b690-a97db725b5a8" -H "apikey: ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Content-Type: application/json" -d "{\"download_url\": \"https://gitea.catelli.it/brontomark/flux/releases/download/${{ github.ref_name }}/app-release.apk\"}"
|
||||
|
||||
- name: Aggiorna Link Windows su Supabase
|
||||
run: |
|
||||
curl -X PATCH "https://pvqpjloswwvtfoxbkfbh.supabase.co/rest/v1/app_config?id=eq.1f888b30-5cbf-4a16-820c-5036a3af0cf8" -H "apikey: ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_KEY }}" -H "Content-Type: application/json" -d "{\"download_url\": \"https://gitea.catelli.it/brontomark/flux/releases/download/${{ github.ref_name }}/FluxInstaller.exe\"}"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# JOB 3: WEB & CLOUDFLARE DEPLOY (Gira sul tuo MacBook)
|
||||
# -----------------------------------------------------------------
|
||||
build-web:
|
||||
runs-on: macos-runner # <--- Etichetta del tuo Mac
|
||||
steps:
|
||||
- name: Checkout del codice
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Crea file .env
|
||||
run: |
|
||||
cat << 'EOF' > .env
|
||||
${{ secrets.ENV_FILE_CONTENT }}
|
||||
EOF
|
||||
|
||||
- name: Build Flutter Web
|
||||
run: flutter build web --release
|
||||
|
||||
# Sfruttiamo npx (incluso in Node.js) per lanciare wrangler al volo senza installarlo globalmente
|
||||
# Sto assumendo che usi Cloudflare Pages che è perfetto per Flutter Web statico
|
||||
- name: Deploy su Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npx wrangler pages deploy build/web --project-name="flux" --branch="main"
|
||||
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"deno.enable": true
|
||||
}
|
||||
@@ -7,6 +7,10 @@ analyzer:
|
||||
- "lib/l10n/*.dart"
|
||||
- "**/*.g.dart" # Già che ci siamo escludiamo tutti i file generati (tipo quelli di JsonSerializable)
|
||||
- "**/*.freezed.dart"
|
||||
- "build/**"
|
||||
- "ios/**"
|
||||
- "macos/**"
|
||||
- ".dart_tool/**"
|
||||
|
||||
linter:
|
||||
rules:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// START: FlutterFire Configuration
|
||||
id("com.google.gms.google-services")
|
||||
// END: FlutterFire Configuration
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
|
||||
29
android/app/google-services.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "249756116297",
|
||||
"project_id": "flux-87e49",
|
||||
"storage_bucket": "flux-87e49.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:249756116297:android:a2c3d37323752069cf2698",
|
||||
"android_client_info": {
|
||||
"package_name": "com.catellisrl.flux"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyA6-uX6504B3yofeo7YQwfQaS0cCDoZnvY"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
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
|
||||
|
||||
@@ -20,6 +20,9 @@ pluginManagement {
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
// START: FlutterFire Configuration
|
||||
id("com.google.gms.google-services") version("4.3.15") apply false
|
||||
// END: FlutterFire Configuration
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
|
||||
BIN
assets/icon/icon.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
1
firebase.json
Normal file
@@ -0,0 +1 @@
|
||||
{"flutter":{"platforms":{"android":{"default":{"projectId":"flux-87e49","appId":"1:249756116297:android:a2c3d37323752069cf2698","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"flux-87e49","appId":"1:249756116297:ios:fe9dadca7150da16cf2698","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"macos":{"default":{"projectId":"flux-87e49","appId":"1:249756116297:ios:fe9dadca7150da16cf2698","uploadDebugSymbols":false,"fileOutput":"macos/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"flux-87e49","configurations":{"android":"1:249756116297:android:a2c3d37323752069cf2698","ios":"1:249756116297:ios:fe9dadca7150da16cf2698","macos":"1:249756116297:ios:fe9dadca7150da16cf2698","web":"1:249756116297:web:7c652e51004414b7cf2698","windows":"1:249756116297:web:b094277c2fedb425cf2698"}}}}}}
|
||||
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"
|
||||
@@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
101B9A4BF8F30D998DDC11D4 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = D4B70082D3146D8C2B7AFA02 /* GoogleService-Info.plist */; };
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
@@ -67,6 +68,7 @@
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
AB44F93458B7D70EE383A3A9 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
BDDDA09E437D9C0E7B65B3B1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
D4B70082D3146D8C2B7AFA02 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -126,6 +128,7 @@
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
F5D002C3092D87755D552D32 /* Pods */,
|
||||
6A991A28CCED9666CA172E00 /* Frameworks */,
|
||||
D4B70082D3146D8C2B7AFA02 /* GoogleService-Info.plist */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -267,6 +270,7 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
101B9A4BF8F30D998DDC11D4 /* GoogleService-Info.plist in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -543,7 +547,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 +604,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 |
30
ios/Runner/GoogleService-Info.plist
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyAllwaoNyqHsZtqfMMo9DxVS6-q7yBwWow</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>249756116297</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.catellisrl.flux</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>flux-87e49</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>flux-87e49.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:249756116297:ios:fe9dadca7150da16cf2698</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,9 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/data/core_repository.dart';
|
||||
import 'package:flux/features/company/models/company_model.dart';
|
||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||
import 'package:flux/features/master_data/store/models/store_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:collection/collection.dart'; // Per firstWhereOrNull
|
||||
@@ -39,35 +45,28 @@ class SessionCubit extends Cubit<SessionState> {
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. CHI È QUESTO UTENTE? (Vediamo se ha un profilo staff, che sia Invitato o Admin)
|
||||
// Riportiamo lo stato su initial per far girare lo spinner se stiamo riprovando
|
||||
emit(state.copyWith(status: SessionStatus.initial, errorMessage: null));
|
||||
|
||||
// WRAP DELLA LOGICA IN UN BLOCCO PROTETTO DA TIMEOUT (10 Secondi)
|
||||
await Future(() async {
|
||||
StaffMemberModel? staff = await _repository.getStaffMemberByUserId(
|
||||
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
|
||||
await _repository.updateStaffMember(staff.id!, {
|
||||
'has_joined': true,
|
||||
});
|
||||
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
|
||||
|
||||
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(
|
||||
@@ -80,7 +79,6 @@ class SessionCubit extends Cubit<SessionState> {
|
||||
emit(state.copyWith(company: company));
|
||||
}
|
||||
|
||||
// 2. Controllo Negozi
|
||||
final stores = await _repository.getStoresByCompanyId(company.id!);
|
||||
if (stores.isEmpty) {
|
||||
return emit(
|
||||
@@ -95,7 +93,6 @@ class SessionCubit extends Cubit<SessionState> {
|
||||
emit(state.copyWith(currentStore: stores.first));
|
||||
}
|
||||
|
||||
// 3. Controllo Staff (Paziente Zero)
|
||||
if (staff == null) {
|
||||
return emit(
|
||||
state.copyWith(
|
||||
@@ -107,21 +104,16 @@ class SessionCubit extends Cubit<SessionState> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- TUTTO COMPLETATO: LOGICA DEL NEGOZIO DI DEFAULT ---
|
||||
|
||||
// Leggiamo l'ultimo negozio dalle SharedPreferences
|
||||
final lastStoreId = _prefs.getString(_lastStoreKey);
|
||||
|
||||
// Cerchiamo quel negozio nella lista. Se non c'è (magari è stato eliminato), prendiamo il primo.
|
||||
final activeStore =
|
||||
stores.firstWhereOrNull((s) => s.id == lastStoreId) ?? stores.first;
|
||||
|
||||
// Se non avevamo il lastStoreId salvato, salviamolo ora
|
||||
if (lastStoreId != activeStore.id && activeStore.id != null) {
|
||||
await _prefs.setString(_lastStoreKey, activeStore.id!);
|
||||
}
|
||||
|
||||
// 4. BENVENUTO A BORDO
|
||||
setIsSingleUserMode(_prefs.getBool('isSingleUserMode') ?? false);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: SessionStatus.authenticated,
|
||||
@@ -129,20 +121,107 @@ class SessionCubit extends Cubit<SessionState> {
|
||||
company: company,
|
||||
currentStore: activeStore,
|
||||
currentStaffMember: staff,
|
||||
onboardingStep: OnboardingStep.none, // Svuotiamo l'onboarding
|
||||
onboardingStep: OnboardingStep.none,
|
||||
),
|
||||
);
|
||||
|
||||
// FCM è fuori dall'await principale, quindi va bene così
|
||||
_registerFcmToken(companyId: company.id!, staffId: staff.id!);
|
||||
}).timeout(
|
||||
const Duration(seconds: 10), // Tempo massimo concesso al server
|
||||
onTimeout: () {
|
||||
throw TimeoutException(
|
||||
'Il server di FLUX non risponde. Controlla la connessione.',
|
||||
);
|
||||
},
|
||||
);
|
||||
} on TimeoutException catch (e) {
|
||||
// 🎯 BINGO! IL TIMEOUT È SCATTATO
|
||||
debugPrint("Timeout Inizializzazione: ${e.message}");
|
||||
emit(
|
||||
state.copyWith(status: SessionStatus.error, errorMessage: e.message),
|
||||
);
|
||||
} catch (e) {
|
||||
// Se esplode il database, non lasciamo l'app freezata in 'initial'
|
||||
// Altri errori generici del DB o di rete
|
||||
debugPrint("Errore Inizializzazione: $e");
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: SessionStatus
|
||||
.unauthenticated, // O un nuovo stato SessionStatus.error
|
||||
status: SessionStatus.error,
|
||||
errorMessage: "Si è verificato un errore di connessione imprevisto.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registerFcmToken({
|
||||
required String companyId,
|
||||
required String staffId,
|
||||
}) async {
|
||||
// Scudo anti-crash per lo sviluppo su Linux / Windows
|
||||
if (!kIsWeb &&
|
||||
!Platform.isAndroid &&
|
||||
!Platform.isIOS &&
|
||||
!Platform.isMacOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
|
||||
// 1. Richiesta permessi di notifica
|
||||
final settings = await messaging.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
|
||||
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
|
||||
String? fcmToken;
|
||||
if (kIsWeb) {
|
||||
fcmToken = await messaging.getToken(
|
||||
vapidKey:
|
||||
'BLMUr7crlRghEW6iWtRZ7Y0a74OPAMG9Oh37ewhVP3_5YD9e5RHUeO79sDys6P-7KjOz6I6HiaPqNndmatQlu3g',
|
||||
);
|
||||
} else {
|
||||
fcmToken = await messaging.getToken();
|
||||
}
|
||||
|
||||
if (fcmToken != null) {
|
||||
final supabase = GetIt.I.get<SupabaseClient>();
|
||||
|
||||
// Determiniamo la piattaforma in modo sicuro per Linux
|
||||
String osPlatform = 'web';
|
||||
if (!kIsWeb) {
|
||||
if (Platform.isAndroid) osPlatform = 'android';
|
||||
if (Platform.isIOS) osPlatform = 'ios';
|
||||
if (Platform.isMacOS) osPlatform = 'macos';
|
||||
}
|
||||
|
||||
// 3. UPSERT su Supabase condizionato dal vincolo 'fcm_token'
|
||||
await supabase.from('staff_devices').upsert(
|
||||
{
|
||||
'company_id': companyId,
|
||||
'staff_id': staffId,
|
||||
'fcm_token': fcmToken,
|
||||
'os_platform': osPlatform,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
},
|
||||
onConflict:
|
||||
'fcm_token', // Se il token esiste già, aggiorna questa riga!
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'Dispositivo registrato con successo su FLUX Cloud. Platform: $osPlatform',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debugPrint('Permesso push negato dall\'utente.');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Errore durante la registrazione del dispositivo: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void updateCurrentCompany(CompanyModel newCompany) {
|
||||
emit(state.copyWith(company: newCompany));
|
||||
}
|
||||
@@ -164,4 +243,17 @@ class SessionCubit extends Cubit<SessionState> {
|
||||
void setIsMobileDevice(bool isMobile) {
|
||||
emit(state.copyWith(isMobileDevice: isMobile));
|
||||
}
|
||||
|
||||
void setIsSingleUserMode(bool isSingleUser) {
|
||||
emit(state.copyWith(isSingleUserMode: isSingleUser));
|
||||
}
|
||||
|
||||
void updateCurrentStoreLocally(StoreModel updatedStore) {
|
||||
// Verifichiamo che l'utente stia effettivamente lavorando nel negozio appena modificato
|
||||
if (state.currentStore != null &&
|
||||
state.currentStore!.id == updatedStore.id) {
|
||||
// Emettiamo il nuovo stato sovrascrivendo solo il negozio corrente
|
||||
emit(state.copyWith(currentStore: updatedStore));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ enum SessionStatus {
|
||||
unauthenticated,
|
||||
onboardingRequired,
|
||||
authenticated,
|
||||
error,
|
||||
}
|
||||
|
||||
/// Definisce lo step esatto dell'onboarding (Paranoia Mode)
|
||||
@@ -25,6 +26,8 @@ class SessionState extends Equatable {
|
||||
final StaffMemberModel? currentStaffMember;
|
||||
final OnboardingStep onboardingStep;
|
||||
final bool isMobileDevice;
|
||||
final bool isSingleUserMode;
|
||||
final String? errorMessage;
|
||||
|
||||
const SessionState({
|
||||
this.status = SessionStatus.initial,
|
||||
@@ -34,6 +37,8 @@ class SessionState extends Equatable {
|
||||
this.currentStaffMember,
|
||||
this.onboardingStep = OnboardingStep.none,
|
||||
this.isMobileDevice = false,
|
||||
this.isSingleUserMode = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
/// Metodo per creare una copia dello stato modificando solo i campi necessari
|
||||
@@ -45,6 +50,8 @@ class SessionState extends Equatable {
|
||||
StaffMemberModel? currentStaffMember,
|
||||
OnboardingStep? onboardingStep,
|
||||
bool? isMobileDevice,
|
||||
bool? isSingleUserMode,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return SessionState(
|
||||
status: status ?? this.status,
|
||||
@@ -54,6 +61,8 @@ class SessionState extends Equatable {
|
||||
currentStaffMember: currentStaffMember ?? this.currentStaffMember,
|
||||
onboardingStep: onboardingStep ?? this.onboardingStep,
|
||||
isMobileDevice: isMobileDevice ?? this.isMobileDevice,
|
||||
isSingleUserMode: isSingleUserMode ?? this.isSingleUserMode,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +75,8 @@ class SessionState extends Equatable {
|
||||
currentStaffMember,
|
||||
onboardingStep,
|
||||
isMobileDevice,
|
||||
isSingleUserMode,
|
||||
errorMessage,
|
||||
];
|
||||
|
||||
// Helper rapidi per la UI
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
const String resetPasswordUrl =
|
||||
'https://flux-web-invite.marco-6ba.workers.dev/';
|
||||
@@ -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();
|
||||
@@ -31,7 +32,7 @@ class CoreRepository {
|
||||
Future<CompanyModel?> getCompanyById(String companyId) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('company')
|
||||
.from(Tables.companies)
|
||||
.select()
|
||||
.eq('id', companyId)
|
||||
.maybeSingle();
|
||||
@@ -46,7 +47,7 @@ class CoreRepository {
|
||||
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
|
||||
@@ -62,7 +63,7 @@ class CoreRepository {
|
||||
Future<StaffMemberModel?> getStaffMemberByUserId(String userId) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('staff_member')
|
||||
.from(Tables.staffMembers)
|
||||
.select()
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
@@ -80,7 +81,7 @@ class CoreRepository {
|
||||
Future<CompanyModel> createCompany(CompanyModel company) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('company')
|
||||
.from(Tables.companies)
|
||||
.insert(company.toMap())
|
||||
.select()
|
||||
.single();
|
||||
@@ -94,7 +95,7 @@ class CoreRepository {
|
||||
Future<StoreModel> createStore(StoreModel store) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('store')
|
||||
.from(Tables.stores)
|
||||
.insert(store.toMap())
|
||||
.select()
|
||||
.single();
|
||||
@@ -108,12 +109,12 @@ class CoreRepository {
|
||||
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,
|
||||
});
|
||||
@@ -126,7 +127,7 @@ class CoreRepository {
|
||||
|
||||
// Assegna un membro a un negozio
|
||||
Future<void> assignStaffToStore(String staffId, String storeId) async {
|
||||
await _supabase.from('staff_in_stores').insert({
|
||||
await _supabase.from(Tables.staffInStores).insert({
|
||||
'staff_member_id': staffId,
|
||||
'store_id': storeId,
|
||||
});
|
||||
@@ -136,6 +137,6 @@ class CoreRepository {
|
||||
String staffId,
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
await _supabase.from('staff_member').update(data).eq('id', staffId);
|
||||
await _supabase.from(Tables.staffMembers).update(data).eq('id', staffId);
|
||||
}
|
||||
}
|
||||
|
||||
28
lib/core/enums_and_consts/consts.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
class Tables {
|
||||
static const String appConfig = 'app_config';
|
||||
static const String attachments = 'attachments';
|
||||
static const String brands = 'brands';
|
||||
static const String campaigns = 'campaigns';
|
||||
static const String companies = 'companies';
|
||||
static const String customers = 'customers';
|
||||
static const String documentSequences = 'document_sequences';
|
||||
static const String models = 'models';
|
||||
static const String notes = 'notes';
|
||||
static const String noteCollaborators = 'note_collaborators';
|
||||
static const String operations = 'operations';
|
||||
static const String providerLocations = 'provider_locations';
|
||||
static const String providers = 'providers';
|
||||
static const String providersInStores = 'providers_in_stores';
|
||||
static const String shippingDocuments = 'shipping_documents';
|
||||
static const String staffInStores = 'staff_in_stores';
|
||||
static const String staffMembers = 'staff_members';
|
||||
static const String stores = 'stores';
|
||||
static const String tasks = 'tasks';
|
||||
static const String taskAssignments = 'task_assignments';
|
||||
static const String taskReminders = 'task_reminders';
|
||||
static const String tickets = 'tickets';
|
||||
static const String trackings = 'trackings';
|
||||
}
|
||||
|
||||
const String resetPasswordUrl =
|
||||
'https://flux-web-invite.marco-6ba.workers.dev/';
|
||||
@@ -1,97 +1,401 @@
|
||||
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});
|
||||
|
||||
// Calcoliamo l'indice attivo in base all'URL corrente!
|
||||
int _calculateSelectedIndex(BuildContext context) {
|
||||
final String location = GoRouterState.of(context).uri.path;
|
||||
if (location.startsWith('/master-data')) return 1;
|
||||
if (location.startsWith('/settings')) return 2;
|
||||
return 0; // Default: Dashboard
|
||||
}
|
||||
|
||||
void _onItemTapped(int index, BuildContext context) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
context.go('/');
|
||||
break;
|
||||
case 1:
|
||||
context.go('/master-data');
|
||||
break;
|
||||
case 2:
|
||||
context.go('/settings');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentIndex = _calculateSelectedIndex(context);
|
||||
// Breakpoint: se lo schermo è più largo di 600px, usiamo la Rail laterale
|
||||
final isDesktop = MediaQuery.sizeOf(context).width >= 600;
|
||||
// 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: [
|
||||
NavigationRail(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (index) =>
|
||||
_onItemTapped(index, context),
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: Text(context.l10n.commonDashboard),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.folder_special_outlined),
|
||||
selectedIcon: Icon(Icons.folder_special),
|
||||
label: Text(context.l10n.commonMasterData),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: Text(context.l10n.commonSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Su desktop inietta il menu a sinistra!
|
||||
AppMenu(currentPath: currentPath, isDrawer: false),
|
||||
const VerticalDivider(thickness: 1, width: 1),
|
||||
// Il contenuto della pagina
|
||||
Expanded(child: child),
|
||||
],
|
||||
)
|
||||
: child, // Su mobile il contenuto prende tutto lo schermo...
|
||||
// ... e mettiamo la barra in basso!
|
||||
bottomNavigationBar: isDesktop
|
||||
? null
|
||||
: NavigationBar(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (index) => _onItemTapped(index, context),
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: context.l10n.commonDashboard,
|
||||
: 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),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (widget.isDrawer) Navigator.pop(context);
|
||||
context.goNamed(Routes.home);
|
||||
},
|
||||
child: Text(
|
||||
"FLUX",
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.folder_special_outlined),
|
||||
selectedIcon: Icon(Icons.folder_special),
|
||||
label: context.l10n.commonMasterData,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: context.l10n.commonSettings,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// --- VOCI DI MENU ---
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
width: effectivelyCollapsed ? 72 : 260,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
children: [
|
||||
_buildRouteItem(
|
||||
title: 'Dashboard',
|
||||
icon: Icons.dashboard_outlined,
|
||||
routeName: Routes.home,
|
||||
pathToCheck:
|
||||
'/', // Il path da controllare per colorarlo
|
||||
isCollapsed: effectivelyCollapsed,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// --- SEZIONE OPERATIVA ---
|
||||
_buildHierarchicalItem(
|
||||
title: 'Operatività',
|
||||
icon: Icons.work_outline,
|
||||
basePathToCheck: '/',
|
||||
isCollapsed: effectivelyCollapsed,
|
||||
subItems: [
|
||||
_SubMenuItem(
|
||||
'Operazioni',
|
||||
Routes.operations,
|
||||
'/operations',
|
||||
),
|
||||
_SubMenuItem(
|
||||
'Assistenza',
|
||||
Routes.tickets,
|
||||
'/tickets',
|
||||
),
|
||||
_SubMenuItem('Tasks', Routes.tasks, '/tasks'),
|
||||
_SubMenuItem('Sticky Notes', Routes.notes, '/notes'),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// --- IL MENU GERARCHICO (ANAGRAFICHE) ---
|
||||
_buildHierarchicalItem(
|
||||
title: context.l10n.commonMasterData,
|
||||
icon: Icons.folder_special_outlined,
|
||||
basePathToCheck:
|
||||
'/master-data', // Se il path inizia così, espandi
|
||||
isCollapsed: effectivelyCollapsed,
|
||||
subItems: [
|
||||
_SubMenuItem(
|
||||
"Clienti",
|
||||
Routes.customers,
|
||||
'/master-data/customers',
|
||||
),
|
||||
_SubMenuItem(
|
||||
"Fornitori",
|
||||
Routes.providers,
|
||||
'/master-data/providers',
|
||||
),
|
||||
_SubMenuItem(
|
||||
"Prodotti",
|
||||
Routes.products,
|
||||
'/master-data/products',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
_buildRouteItem(
|
||||
title: context.l10n.commonSettings,
|
||||
icon: Icons.settings_outlined,
|
||||
routeName: Routes.settings,
|
||||
pathToCheck: '/settings',
|
||||
isCollapsed: effectivelyCollapsed,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// --- PULSANTE TOGGLE (Solo Desktop) ---
|
||||
if (!widget.isDrawer)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: IconButton(
|
||||
tooltip: _isCollapsed ? 'Espandi Menu' : 'Riduci Menu',
|
||||
icon: Icon(
|
||||
_isCollapsed
|
||||
? Icons.keyboard_double_arrow_right
|
||||
: Icons.keyboard_double_arrow_left,
|
||||
color: theme.iconTheme.color?.withValues(alpha: 0.5),
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isCollapsed = !_isCollapsed;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// WIDGET HELPER AGGIORNATI
|
||||
// ==========================================
|
||||
|
||||
Widget _buildRouteItem({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required String routeName,
|
||||
required String pathToCheck,
|
||||
required bool isCollapsed,
|
||||
}) {
|
||||
final isSelected = widget.currentPath == pathToCheck;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (isCollapsed) {
|
||||
return Tooltip(
|
||||
message: title,
|
||||
preferBelow: false,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (widget.isDrawer) Navigator.pop(context);
|
||||
context.goNamed(routeName); // <--- goNamed!
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isSelected ? theme.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
selectedTileColor: theme.colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
selected: isSelected,
|
||||
leading: Icon(icon, color: isSelected ? theme.colorScheme.primary : null),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
onTap: () {
|
||||
if (widget.isDrawer) Navigator.pop(context);
|
||||
context.goNamed(routeName); // <--- goNamed!
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHierarchicalItem({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required String basePathToCheck,
|
||||
required bool isCollapsed,
|
||||
required List<_SubMenuItem> subItems,
|
||||
}) {
|
||||
final isSelected = subItems.any(
|
||||
(item) => widget.currentPath.startsWith(item.pathToCheck),
|
||||
);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (isCollapsed) {
|
||||
return PopupMenuButton<String>(
|
||||
tooltip: title,
|
||||
offset: const Offset(60, 0),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onSelected: (routeName) {
|
||||
// Il routeName arriva dal value del menu
|
||||
if (widget.isDrawer) Navigator.pop(context);
|
||||
context.goNamed(routeName); // <--- goNamed!
|
||||
},
|
||||
itemBuilder: (context) => subItems
|
||||
.map(
|
||||
(item) => PopupMenuItem(
|
||||
value: item
|
||||
.routeName, // Passiamo il nome della rotta (Routes.customers)
|
||||
child: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontWeight: widget.currentPath == item.pathToCheck
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: widget.currentPath == item.pathToCheck
|
||||
? theme.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Container(
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isSelected ? theme.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Theme(
|
||||
data: theme.copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: isSelected,
|
||||
maintainState: true,
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: Icon(
|
||||
icon,
|
||||
color: isSelected ? theme.colorScheme.primary : null,
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
children: subItems.map((item) {
|
||||
final isSubSelected = widget.currentPath == item.pathToCheck;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 32.0, bottom: 4.0),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
selectedTileColor: theme.colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
selected: isSubSelected,
|
||||
title: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSubSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: isSubSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.textTheme.bodyMedium?.color,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
onTap: () {
|
||||
if (widget.isDrawer) Navigator.pop(context);
|
||||
context.goNamed(item.routeName); // <--- goNamed!
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Struttura dati per le voci dei sottomenu aggiornata
|
||||
class _SubMenuItem {
|
||||
final String title;
|
||||
final String routeName; // Es: Routes.customers
|
||||
final String pathToCheck; // Es: '/master-data/customers'
|
||||
_SubMenuItem(this.title, this.routeName, this.pathToCheck);
|
||||
}
|
||||
|
||||
@@ -7,47 +7,75 @@ import 'package:flux/core/layout/app_shell.dart';
|
||||
import 'package:flux/core/routes/routes.dart';
|
||||
import 'package:flux/core/widgets/image_upload/blocs/image_upload_cubit.dart';
|
||||
import 'package:flux/core/widgets/image_upload/ui/image_upload_screen.dart';
|
||||
import 'package:flux/core/widgets/set_password_screen.dart';
|
||||
import 'package:flux/core/widgets/image_upload/ui/upload_success_screen.dart';
|
||||
import 'package:flux/features/auth/ui/auth_screen.dart';
|
||||
import 'package:flux/features/auth/ui/set_password_screen.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/customers_cubit.dart';
|
||||
import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
|
||||
import 'package:flux/features/customers/blocs/customers_list_cubit.dart';
|
||||
import 'package:flux/features/customers/models/customer_model.dart';
|
||||
import 'package:flux/features/customers/ui/customer_detail_screen.dart';
|
||||
import 'package:flux/features/customers/ui/customers_content.dart';
|
||||
import 'package:flux/features/customers/ui/customer_form_screen.dart';
|
||||
import 'package:flux/features/customers/ui/customers_list_screen.dart';
|
||||
import 'package:flux/features/home/dashboard_note_list/blocs/dashboard_note_list_cubit.dart';
|
||||
import 'package:flux/features/home/dashboard_store_operation_list/bloc/dashboard_store_operation_list_cubit.dart';
|
||||
import 'package:flux/features/home/dashboard_store_ticket_list/blocs/dashboard_store_ticket_list_cubit.dart';
|
||||
import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
|
||||
import 'package:flux/features/home/ui/home_screen.dart';
|
||||
import 'package:flux/features/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_cubit.dart';
|
||||
import 'package:flux/features/master_data/providers/ui/providers_master_data_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/blocs/staff_cubit.dart';
|
||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
|
||||
import 'package:flux/features/master_data/store/bloc/store_cubit.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/attachments/blocs/attachments_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
|
||||
import 'package:flux/features/operations/blocs/operation_list_cubit.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:flux/features/operations/ui/operation_form_screen.dart';
|
||||
import 'package:flux/features/operations/ui/operation_list_screen.dart';
|
||||
import 'package:flux/features/settings/settings_view.dart';
|
||||
import 'package:flux/features/settings/theme_settings_view.dart';
|
||||
import 'package:flux/features/settings/blocs/reminder_defaults_cubit.dart';
|
||||
import 'package:flux/features/settings/ui/reminder_settings_screen.dart';
|
||||
import 'package:flux/features/settings/ui/settings_screen.dart';
|
||||
import 'package:flux/features/settings/ui/theme_settings_view.dart';
|
||||
import 'package:flux/features/tasks/blocs/task_form_cubit.dart';
|
||||
import 'package:flux/features/tasks/blocs/task_list_cubit.dart';
|
||||
import 'package:flux/features/tasks/models/task_model.dart';
|
||||
import 'package:flux/features/tasks/ui/task_form_screen.dart';
|
||||
import 'package:flux/features/tasks/ui/task_list_screen.dart';
|
||||
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
|
||||
import 'package:flux/features/tickets/blocs/ticket_list_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';
|
||||
|
||||
class AppRouter {
|
||||
// 1. CREIAMO LA CHIAVE GLOBALE DEL NAVIGATORE
|
||||
static final GlobalKey<NavigatorState> rootNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
static String? pendingRoute;
|
||||
static GoRouter createRouter(SessionCubit sessionCubit) {
|
||||
return GoRouter(
|
||||
navigatorKey: rootNavigatorKey,
|
||||
initialLocation: '/',
|
||||
refreshListenable: GoRouterRefreshStream(sessionCubit.stream),
|
||||
|
||||
redirect: (context, state) {
|
||||
final sessionState = sessionCubit.state;
|
||||
final isGoingToLogin = state.matchedLocation == '/login';
|
||||
@@ -117,94 +145,189 @@ class AppRouter {
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppShell(child: child),
|
||||
routes: [
|
||||
// ==========================================
|
||||
// 1. DASHBOARD
|
||||
// ==========================================
|
||||
GoRoute(
|
||||
path: '/',
|
||||
name: Routes.home,
|
||||
builder: (context, state) => const HomeScreen(),
|
||||
builder: (context, state) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<DashboardStoreOperationListCubit>(
|
||||
create: (context) => DashboardStoreOperationListCubit(
|
||||
companyId: sessionCubit.state.company?.id,
|
||||
storeId: sessionCubit.state.currentStore?.id,
|
||||
),
|
||||
),
|
||||
BlocProvider<DashboardTaskListCubit>(
|
||||
create: (context) => DashboardTaskListCubit(
|
||||
companyId: sessionCubit.state.company?.id,
|
||||
staffId: sessionCubit.state.currentStaffMember?.id,
|
||||
),
|
||||
),
|
||||
BlocProvider<DashboardStoreTicketListCubit>(
|
||||
create: (context) => DashboardStoreTicketListCubit(
|
||||
companyId: sessionCubit.state.company?.id,
|
||||
storeId: sessionCubit.state.currentStore?.id,
|
||||
),
|
||||
),
|
||||
BlocProvider<DashboardNoteListCubit>(
|
||||
create: (context) => DashboardNoteListCubit(
|
||||
companyId: sessionCubit.state.company?.id,
|
||||
staffId: sessionCubit.state.currentStaffMember?.id,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const HomeScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// ==========================================
|
||||
// 2. HUB ANAGRAFICHE E SOTTO-ROTTE
|
||||
// ==========================================
|
||||
GoRoute(
|
||||
path: '/master-data',
|
||||
name: Routes.masterData,
|
||||
builder: (context, state) => const MasterDataHubScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'products', // Diventa /master-data/products
|
||||
path:
|
||||
'customers', // Niente slash iniziale per le sottorotte! -> /master-data/customers
|
||||
name: Routes.customers,
|
||||
builder: (context, state) => const CustomersListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'providers', // -> /master-data/providers
|
||||
name: Routes.providers,
|
||||
builder: (context, state) => const ProviderListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'products', // -> /master-data/products
|
||||
name: Routes.products,
|
||||
builder: (context, state) {
|
||||
context.read<ProductsCubit>().refreshCubit();
|
||||
|
||||
return const ProductsScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'company-settings',
|
||||
path: 'staff', // -> /master-data/staff
|
||||
name: Routes.staff,
|
||||
builder: (context, state) => const StaffScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path:
|
||||
'stores', // Sistemata l'inversione path/name -> /master-data/stores
|
||||
name: Routes.stores,
|
||||
builder: (context, state) {
|
||||
context.read<ProviderListCubit>().loadAllProviders();
|
||||
context.read<StoreCubit>().loadStores();
|
||||
return const StoresScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'company-settings', // -> /master-data/company-settings
|
||||
name: Routes.companySettings,
|
||||
builder: (context, state) => BlocProvider(
|
||||
create: (context) => CompanySettingsCubit(),
|
||||
child: const CompanySettingsScreen(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// ==========================================
|
||||
// 3. IMPOSTAZIONI
|
||||
// ==========================================
|
||||
GoRoute(
|
||||
path: 'staff',
|
||||
name: Routes.staff, // Diventa /master-data/staff
|
||||
builder: (context, state) => const StaffScreen(),
|
||||
path: '/settings',
|
||||
name: Routes.settings,
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'themeSettings', // -> /settings/themeSettings
|
||||
name: Routes.themeSettings,
|
||||
builder: (context, state) => const ThemeSettingsView(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.stores,
|
||||
name: 'stores', // Diventa /master-data/stores
|
||||
builder: (context, state) => const StoresScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'providers',
|
||||
name: Routes.providers, // Diventa /master-data/providers
|
||||
path: 'reminderSettings',
|
||||
name: Routes.reminderSettings,
|
||||
builder: (context, state) =>
|
||||
const ProvidersMasterDataScreen(),
|
||||
BlocProvider<ReminderDefaultsCubit>(
|
||||
create: (context) => ReminderDefaultsCubit(),
|
||||
|
||||
child: const ReminderSettingsScreen(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 3. IMPOSTAZIONI
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
name: Routes.settings,
|
||||
builder: (context, state) => const SettingsView(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '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) => BlocProvider(
|
||||
create: (context) => OperationListCubit(),
|
||||
child: const OperationListScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/customers',
|
||||
name: Routes.customers,
|
||||
builder: (context, state) =>
|
||||
const CustomersContent(), // O come si chiama il tuo widget della lista!
|
||||
builder: (context, state) => const OperationListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tickets',
|
||||
name: Routes.tickets,
|
||||
builder: (context, state) => BlocProvider(
|
||||
create: (context) => TicketListCubit(),
|
||||
child: const TicketListScreen(),
|
||||
builder: (context, state) => const TicketListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/notes',
|
||||
name: Routes.notes,
|
||||
builder: (context, state) => const NotesListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tasks',
|
||||
name: Routes.tasks,
|
||||
builder: (context, state) {
|
||||
// 1. Recuperiamo lo stato della sessione per le dipendenze
|
||||
final sessionState = context.read<SessionCubit>().state;
|
||||
|
||||
// Sicurezza: Se per qualche motivo non abbiamo l'azienda,
|
||||
// qui potresti reindirizzare o gestire l'errore
|
||||
final companyId = sessionState.company?.id;
|
||||
if (companyId == null) {
|
||||
return const Scaffold(
|
||||
body: Center(child: Text("Errore: Azienda non trovata")),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Iniettiamo il Cubit con tutto ciò che gli serve
|
||||
return BlocProvider(
|
||||
create: (context) => TaskListCubit(
|
||||
currentCompanyId: companyId,
|
||||
currentStoreId: sessionState
|
||||
.currentStore
|
||||
?.id, // Opzionale: filtra per negozio se l'utente è "dentro" uno store
|
||||
),
|
||||
child: const TaskListScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
|
||||
GoRoute(
|
||||
path: '/providers/form',
|
||||
name: Routes.providerForm,
|
||||
builder: (context, state) {
|
||||
// Estraiamo il fornitore (se stiamo modificando e non creando)
|
||||
final existingProvider = state.extra as ProviderModel?;
|
||||
|
||||
return BlocProvider<ProviderFormCubit>(
|
||||
// Inizializziamo un Cubit NUOVO ogni volta che apriamo il form
|
||||
create: (context) => ProviderFormCubit(),
|
||||
child: ProviderFormScreen(existingProvider: existingProvider),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
// Il path sarà es. /tickets/form/123 oppure /tickets/form/new
|
||||
path: '/tickets/form/:id',
|
||||
@@ -213,15 +336,29 @@ class AppRouter {
|
||||
// 1. Leggiamo l'ID dall'URL
|
||||
final String pathId = state.pathParameters['id'] ?? 'new';
|
||||
|
||||
// 2. Leggiamo l'oggetto dalla RAM (se arriviamo da un tap interno all'app)
|
||||
final TicketModel? ticketFromExtra = state.extra as TicketModel?;
|
||||
// 2. CAST DA NINJA (Aggiungi i punti interrogativi!)
|
||||
final record =
|
||||
state.extra
|
||||
as ({StaffMemberModel? createdBy, TicketModel? ticket})?;
|
||||
|
||||
// 3. Capiamo se è un nuovo ticket o una modifica
|
||||
final String? realTicketId = pathId == 'new' ? null : pathId;
|
||||
context.read<StaffCubit>().loadStaffForStore(
|
||||
GetIt.I.get<SessionCubit>().state.currentStore!.id!,
|
||||
// 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<CustomersCubit>().loadCustomers();
|
||||
}
|
||||
context.read<CustomersListCubit>().loadCustomers();
|
||||
context.read<ProductsCubit>().loadModels();
|
||||
context.read<ProductsCubit>().loadBrands();
|
||||
|
||||
@@ -233,24 +370,60 @@ class AppRouter {
|
||||
parentId: realTicketId,
|
||||
),
|
||||
),
|
||||
BlocProvider(create: (context) => TicketFormCubit()),
|
||||
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: ticketFromExtra,
|
||||
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/form/:id',
|
||||
name: 'customer-form',
|
||||
path: '/customer/details/:id',
|
||||
name: Routes.customerDetails,
|
||||
builder: (context, state) {
|
||||
final customer = state.extra as CustomerModel;
|
||||
return BlocProvider(
|
||||
@@ -262,28 +435,62 @@ class AppRouter {
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/customer/form/:id',
|
||||
name: Routes.customerForm,
|
||||
builder: (context, state) {
|
||||
final String pathId = state.pathParameters['id'] ?? 'new';
|
||||
final String? realCustomerId;
|
||||
if (pathId == 'new') {
|
||||
realCustomerId = null;
|
||||
} else {
|
||||
realCustomerId = pathId;
|
||||
}
|
||||
final customer = state.extra as CustomerModel?;
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) => CustomerFormCubit(
|
||||
existingCustomer: customer,
|
||||
customerId: realCustomerId,
|
||||
),
|
||||
child: CustomerFormScreen(
|
||||
customer: customer,
|
||||
customerId: realCustomerId,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
path: '/operations/form/:id',
|
||||
name: Routes.operationForm,
|
||||
builder: (context, state) {
|
||||
final String pathId = state.pathParameters['id'] ?? 'new';
|
||||
final OperationModel? operationFromExtra =
|
||||
state.extra as OperationModel?;
|
||||
final String? realOperationId = pathId == 'new' ? null : pathId;
|
||||
|
||||
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<CustomersCubit>().loadCustomers();
|
||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||
currentStoreId,
|
||||
);
|
||||
context.read<CustomersListCubit>().loadCustomers();
|
||||
context.read<ProviderListCubit>().loadProviders(currentStoreId);
|
||||
context.read<ProductsCubit>().loadModels();
|
||||
context.read<ProductsCubit>().loadBrands();
|
||||
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
@@ -292,11 +499,16 @@ class AppRouter {
|
||||
parentType: AttachmentParentType.operation,
|
||||
),
|
||||
),
|
||||
BlocProvider(create: (context) => OperationFormCubit()),
|
||||
BlocProvider(
|
||||
create: (context) => OperationFormCubit(
|
||||
createdBy: record?.createdBy,
|
||||
existingOperation: record?.operation,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: OperationFormScreen(
|
||||
operationId: realOperationId,
|
||||
existingOperation: operationFromExtra,
|
||||
existingOperation: record?.operation,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -334,6 +546,66 @@ class AppRouter {
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/notes/edit/:id',
|
||||
name: Routes.noteForm,
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
final NoteModel note = state.extra as NoteModel;
|
||||
|
||||
// Creiamo il BLoC "al volo" solo per questa schermata
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<AttachmentsBloc>(
|
||||
create: (context) => AttachmentsBloc(
|
||||
parentId: id,
|
||||
parentType: AttachmentParentType.note,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
child: NoteFormScreen(note: note),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tasks/form/:id',
|
||||
name: Routes.taskForm,
|
||||
builder: (context, state) {
|
||||
final String pathId = state.pathParameters['id'] ?? 'new';
|
||||
final TaskModel? task = state.extra as TaskModel?;
|
||||
final String? realTaskId;
|
||||
if (pathId == 'new') {
|
||||
realTaskId = null;
|
||||
} else if (task?.id != null) {
|
||||
realTaskId = task!.id;
|
||||
} else {
|
||||
realTaskId = pathId;
|
||||
}
|
||||
|
||||
List<StaffMemberModel>? preloadedStaff;
|
||||
try {
|
||||
preloadedStaff = context.read<StaffCubit>().state.allStaff;
|
||||
} catch (_) {
|
||||
preloadedStaff = null; // Fallback se la rotta è isolata
|
||||
}
|
||||
|
||||
// Creiamo il BLoC "al volo" solo per questa schermata
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<TaskFormCubit>(
|
||||
create: (context) => TaskFormCubit(
|
||||
existingTask: task,
|
||||
initialTaskId: realTaskId,
|
||||
allStaff: preloadedStaff,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
child: TaskFormScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class Routes {
|
||||
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';
|
||||
@@ -18,5 +19,12 @@ class Routes {
|
||||
static const String operationForm = 'operation-form';
|
||||
static const String uploadSuccess = 'upload-success';
|
||||
static const String customerForm = 'customer-form';
|
||||
static const String customerDetails = 'customer-details';
|
||||
static const String upload = 'upload';
|
||||
static const String ticketWorkspace = 'ticket-workspace';
|
||||
static const String noteForm = 'note-form';
|
||||
static const String notes = 'notes';
|
||||
static const String tasks = 'tasks';
|
||||
static const String taskForm = 'task-form';
|
||||
static const String reminderSettings = 'reminder-settings';
|
||||
}
|
||||
|
||||
36
lib/core/services/notification_service.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flux/core/routes/app_router.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
// Chiamala dopo l'autenticazione o nel main()
|
||||
Future<void> setupInteractedMessage() async {
|
||||
// CASO A: L'app era completamente CHIUSA e viene aperta tappando la notifica
|
||||
RemoteMessage? initialMessage = await FirebaseMessaging.instance
|
||||
.getInitialMessage();
|
||||
if (initialMessage != null) {
|
||||
_handleNotificationTap(initialMessage);
|
||||
}
|
||||
|
||||
// CASO B: L'app era in BACKGROUND (minimizzata) e l'utente tappa la notifica
|
||||
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
|
||||
}
|
||||
|
||||
void _handleNotificationTap(RemoteMessage message) {
|
||||
final eventType = message.data['eventType'];
|
||||
final referenceId = message.data['referenceId'];
|
||||
|
||||
if (eventType == 'task_assigned' && referenceId != null) {
|
||||
final routePath = '/tasks/form/$referenceId';
|
||||
final context = AppRouter.rootNavigatorKey.currentContext;
|
||||
|
||||
if (context != null) {
|
||||
// Scenario A: App già aperta, naviga all'istante
|
||||
context.push(routePath);
|
||||
} else {
|
||||
// Scenario B: App chiusa. Il contesto non c'è ancora, congeliamo la rotta!
|
||||
debugPrint("App in fase di avvio. Congelo la rotta: $routePath");
|
||||
AppRouter.pendingRoute = routePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
94
lib/core/utils/version_check_service.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class VersionCheckService {
|
||||
Future<String?> checkForceUpdate() async {
|
||||
try {
|
||||
// 1. Capiamo su che piattaforma sta girando l'app in questo istante
|
||||
String currentPlatform = _getCurrentPlatform();
|
||||
|
||||
// 2. Recuperiamo SOLO la riga corrispondente alla nostra piattaforma
|
||||
final dbResponse = await Supabase.instance.client
|
||||
.from('app_config')
|
||||
.select('min_version, download_url')
|
||||
.eq('platform', currentPlatform)
|
||||
.maybeSingle(); // Usiamo maybeSingle così se non c'è la riga non crasha
|
||||
|
||||
if (dbResponse == null) {
|
||||
return null; // Nessuna regola per questa piattaforma
|
||||
}
|
||||
|
||||
String minVersionFromDb = dbResponse['min_version'] as String;
|
||||
String downloadUrl = dbResponse['download_url'] as String;
|
||||
|
||||
// 3. Recuperiamo la versione locale di Flutter
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
String localVersionRaw = packageInfo.version;
|
||||
|
||||
// 🥷 TRUCCO 1: Pulizia totale dai build number (+37) o tag "v"
|
||||
String cleanLocal = localVersionRaw
|
||||
.split('+')
|
||||
.first
|
||||
.replaceAll('v', '')
|
||||
.trim();
|
||||
String cleanDb = minVersionFromDb
|
||||
.split('+')
|
||||
.first
|
||||
.replaceAll('v', '')
|
||||
.trim();
|
||||
|
||||
// 🥷 TRUCCO 2: Confronto Semantico Reale
|
||||
if (_isVersionLower(current: cleanLocal, minimum: cleanDb)) {
|
||||
// Ritorna il link VERO per questa specifica piattaforma preso dal CSV!
|
||||
return downloadUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint("Errore durante il check versione: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper ninja per mappare le piattaforme in base alle stringhe del tuo DB
|
||||
String _getCurrentPlatform() {
|
||||
if (kIsWeb) return 'web';
|
||||
if (Platform.isAndroid) return 'android';
|
||||
if (Platform.isIOS) return 'ios';
|
||||
if (Platform.isWindows) return 'windows';
|
||||
if (Platform.isMacOS) return 'macos';
|
||||
if (Platform.isLinux) return 'linux';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// Il motore matematico (resta invariato)
|
||||
bool _isVersionLower({required String current, required String minimum}) {
|
||||
if (current == minimum) return false;
|
||||
|
||||
List<int> currentParts = current
|
||||
.split('.')
|
||||
.map((e) => int.tryParse(e) ?? 0)
|
||||
.toList();
|
||||
List<int> minParts = minimum
|
||||
.split('.')
|
||||
.map((e) => int.tryParse(e) ?? 0)
|
||||
.toList();
|
||||
|
||||
while (currentParts.length < 3) {
|
||||
currentParts.add(0);
|
||||
}
|
||||
while (minParts.length < 3) {
|
||||
minParts.add(0);
|
||||
}
|
||||
|
||||
if (currentParts[0] != minParts[0]) {
|
||||
return currentParts[0] < minParts[0];
|
||||
}
|
||||
if (currentParts[1] != minParts[1]) {
|
||||
return currentParts[1] < minParts[1];
|
||||
}
|
||||
return currentParts[2] < minParts[2];
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class FluxTextField extends StatefulWidget {
|
||||
final TextCapitalization? textCapitalization;
|
||||
final bool? autocorrect;
|
||||
final bool? enabled;
|
||||
final Iterable<String>? autofillHints;
|
||||
|
||||
const FluxTextField({
|
||||
super.key, // Usiamo super.key per Flutter moderno
|
||||
@@ -41,6 +42,7 @@ class FluxTextField extends StatefulWidget {
|
||||
this.textCapitalization,
|
||||
this.autocorrect,
|
||||
this.enabled = true,
|
||||
this.autofillHints,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -118,6 +120,7 @@ class _FluxTextFieldState extends State<FluxTextField> {
|
||||
|
||||
textCapitalization: widget.textCapitalization ?? TextCapitalization.none,
|
||||
enabled: widget.enabled,
|
||||
autofillHints: widget.autofillHints,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/core/widgets/flux_text_field.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class SetPasswordScreen extends StatefulWidget {
|
||||
const SetPasswordScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SetPasswordScreen> createState() => _SetPasswordScreenState();
|
||||
}
|
||||
|
||||
class _SetPasswordScreenState extends State<SetPasswordScreen> {
|
||||
final _passwordCtrl = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _savePassword() async {
|
||||
final newPassword = _passwordCtrl.text.trim();
|
||||
if (newPassword.length < 6) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.setPasswordScreenAtLeast6Chars)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
// 1. Aggiorniamo la password dell'utente (che Supabase ha già loggato grazie al link della mail)
|
||||
await GetIt.I.get<SupabaseClient>().auth.updateUser(
|
||||
UserAttributes(password: newPassword),
|
||||
);
|
||||
|
||||
// 2. Finito! Lo mandiamo alla home o facciamo ricaricare la sessione al SessionCubit
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.setPasswordScreenPasswordSetWelcome),
|
||||
),
|
||||
);
|
||||
context.go('/'); // Rimandiamo al router principale
|
||||
}
|
||||
} on AuthException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.authError(e.message))),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.commonError(e.toString()))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.setPasswordScreenWelcomeInFlux),
|
||||
automaticallyImplyLeading:
|
||||
false, // Non può tornare indietro, deve mettere la password!
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.lock_reset, size: 80, color: Colors.blueAccent),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.setPasswordScreenSetPassword,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.setPasswordInviteAcceptedChoosePassword,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FluxTextField(
|
||||
controller: _passwordCtrl,
|
||||
label: context.l10n.commonNewPassword,
|
||||
icon: Icons.lock,
|
||||
isPassword: true,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _savePassword,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: Text(
|
||||
context.l10n.setPasswordScreenSaveAndStart,
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,14 +34,14 @@ class SharedAttachmentsSection extends StatefulWidget {
|
||||
final String? parentId;
|
||||
final String titleForUpload;
|
||||
final AttachmentParentType parentType;
|
||||
final Future<String?> Function()? onGenerateIdForQr;
|
||||
final Future<String?> Function()? onEnsureEntitySaved;
|
||||
|
||||
const SharedAttachmentsSection({
|
||||
super.key,
|
||||
this.parentId,
|
||||
this.titleForUpload = 'Cliente_sconosciuto',
|
||||
required this.parentType,
|
||||
this.onGenerateIdForQr,
|
||||
this.onEnsureEntitySaved,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -90,6 +90,32 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
||||
|
||||
// --- SELEZIONE FILE DAL PC/TELEFONO ---
|
||||
Future<void> _pickFiles() async {
|
||||
final attachmentsBloc = context.read<AttachmentsBloc>();
|
||||
String? targetId = attachmentsBloc.state.parentId;
|
||||
|
||||
// 🥷 SE L'ID NON C'È (Nuova Operazione), FORZIAMO IL SALVATAGGIO PREVENTIVO!
|
||||
if (targetId == null || targetId.isEmpty) {
|
||||
if (widget.onEnsureEntitySaved != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Salvataggio rapido scheda per allegati... ⏳'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
|
||||
// Chiamiamo la funzione passata dal TicketForm/OperationForm
|
||||
targetId = await widget.onEnsureEntitySaved!();
|
||||
}
|
||||
|
||||
// Se il salvataggio fallisce (es. form non valido), ci fermiamo per evitare file orfani
|
||||
if (targetId == null || targetId.isEmpty) return;
|
||||
|
||||
// Comunichiamo immediatamente al BLoC che l'entità padre è stata salvata e ha un nuovo ID.
|
||||
// Questo eviterà che i file finiscano nei `localFiles` temporanei.
|
||||
attachmentsBloc.add(ParentEntitySavedEvent(targetId));
|
||||
}
|
||||
|
||||
// Ora che abbiamo la certezza matematica di avere un targetId, apriamo il picker
|
||||
final result = await FilePicker.pickFiles(
|
||||
allowMultiple: true,
|
||||
type: FileType.custom,
|
||||
@@ -98,8 +124,8 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
||||
);
|
||||
|
||||
if (result != null && mounted) {
|
||||
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
|
||||
context.read<AttachmentsBloc>().add(AddAttachmentsEvent(result.files));
|
||||
// Ora il BLoC eseguirà l'ambiente di "Upload immediato" (Bivio 2) perché ha l'ID aggiornato!
|
||||
attachmentsBloc.add(AddAttachmentsEvent(result.files));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +179,8 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
||||
fileBytes = file.localBytes;
|
||||
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
|
||||
fileBytes = await repository.downloadAttachmentBytes(
|
||||
file.storagePath!,
|
||||
storagePath: file.storagePath!,
|
||||
bucket: Bucket.documents,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,7 +310,8 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
||||
fileBytes = file.localBytes;
|
||||
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
|
||||
fileBytes = await repository.downloadAttachmentBytes(
|
||||
file.storagePath!,
|
||||
storagePath: file.storagePath!,
|
||||
bucket: Bucket.documents,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -424,7 +452,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: const Text(
|
||||
'Cartella Export (Es. TIM AttachmentRepository)',
|
||||
'Cartella Export PDF',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
@@ -479,7 +507,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
||||
|
||||
// SE L'ID NON C'È, CHIAMIAMO IL SALVATAGGIO IN BACKGROUND!
|
||||
if (targetId == null) {
|
||||
if (widget.onGenerateIdForQr != null) {
|
||||
if (widget.onEnsureEntitySaved != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
@@ -490,7 +518,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
||||
);
|
||||
|
||||
// Aspettiamo che il TicketFormCubit faccia il suo lavoro
|
||||
targetId = await widget.onGenerateIdForQr!();
|
||||
targetId = await widget.onEnsureEntitySaved!();
|
||||
}
|
||||
|
||||
// Se fallisce (es. validazione form non passata), ci fermiamo
|
||||
@@ -561,8 +589,6 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Azioni visibili SOLO se c'è una selezione!
|
||||
if (hasSelection) ...[
|
||||
// Bottone Elimina
|
||||
@@ -661,6 +687,7 @@ class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.upload_file, size: 48, color: Colors.grey),
|
||||
SizedBox(height: 8),
|
||||
|
||||
@@ -2,7 +2,8 @@ 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/customers_cubit.dart';
|
||||
import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
|
||||
import 'package:flux/features/customers/blocs/customers_list_cubit.dart';
|
||||
import 'package:flux/features/customers/models/customer_model.dart';
|
||||
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
@@ -64,11 +65,17 @@ class SharedCustomerSection extends StatelessWidget {
|
||||
if (hasCustomer) ...[
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
onPressed: () => context.pushNamed(
|
||||
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),
|
||||
),
|
||||
],
|
||||
@@ -253,7 +260,7 @@ class SharedCustomerSection extends StatelessWidget {
|
||||
),
|
||||
onChanged: (query) {
|
||||
currentSearchQuery = query;
|
||||
context.read<CustomersCubit>().searchCustomers(query);
|
||||
context.read<CustomersListCubit>().searchCustomers(query);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -272,11 +279,14 @@ class SharedCustomerSection extends StatelessWidget {
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<CustomersCubit>(),
|
||||
value: context.read<CustomersListCubit>(),
|
||||
child: BlocProvider<CustomerFormCubit>(
|
||||
create: (context) => CustomerFormCubit(),
|
||||
child: QuickCustomerDialog(
|
||||
initialQuery:
|
||||
currentSearchQuery, // <-- Passiamo quello che ha digitato!
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -297,9 +307,9 @@ class SharedCustomerSection extends StatelessWidget {
|
||||
const Divider(),
|
||||
// Lista Clienti dal Bloc
|
||||
Expanded(
|
||||
child: BlocBuilder<CustomersCubit, CustomersState>(
|
||||
child: BlocBuilder<CustomersListCubit, CustomersListState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == CustomersStatus.loading) {
|
||||
if (state.status == CustomersListStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.customers.isEmpty) {
|
||||
|
||||
@@ -83,6 +83,8 @@ class SharedModelSection extends StatelessWidget {
|
||||
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),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -98,6 +99,8 @@ class SharedFilesSection extends StatelessWidget {
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BlocProvider.value(
|
||||
value: bloc,
|
||||
child: BlocProvider<ImageUploadCubit>(
|
||||
create: (context) => ImageUploadCubit(),
|
||||
child: ImageUploadScreen(
|
||||
title: titleNameForUpload,
|
||||
companyId: GetIt.I
|
||||
@@ -108,6 +111,7 @@ class SharedFilesSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
@@ -14,7 +16,6 @@ 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(
|
||||
@@ -36,15 +37,18 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
||||
on<SelectAllAttachmentsEvent>(_onSelectAllAttachments);
|
||||
on<ClearAttachmentSelectionEvent>(_onClearAttachmentSelection);
|
||||
|
||||
// Se il BLoC nasce già con un ID, carichiamo i file
|
||||
if (parentId != null && companyId != null) {
|
||||
final currentCompanyId = GetIt.I.get<SessionCubit>().state.company?.id;
|
||||
if (parentId != null && currentCompanyId != null) {
|
||||
add(LoadAttachmentsEvent(parentId: parentId));
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onParentEntitySaved(
|
||||
ParentEntitySavedEvent event,
|
||||
Emitter<AttachmentsState> emit,
|
||||
) async {
|
||||
final companyId = GetIt.I.get<SessionCubit>().state.company?.id;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
parentId: event.newParentId,
|
||||
@@ -67,6 +71,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
||||
parentType: state.parentType,
|
||||
pickedFile: fakePlatformFile,
|
||||
companyId: companyId!,
|
||||
bucket: _getBucketForParentType,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
@@ -115,14 +120,30 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
||||
Emitter<AttachmentsState> emit,
|
||||
) async {
|
||||
final currentId = state.parentId;
|
||||
final currentCompanyId = GetIt.I.get<SessionCubit>().state.company?.id;
|
||||
|
||||
// BIVIO 1: PRATICA NUOVA (Salvataggio locale)
|
||||
if (currentCompanyId == null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AttachmentsStatus.failure,
|
||||
error: "Company ID non trovato nella sessione",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// BIVIO 1: PRATICA NUOVA (Salvataggio locale in memoria)
|
||||
if (currentId == null) {
|
||||
final newLocalFiles = event.files.map((file) {
|
||||
// Assegniamo i campi dinamicamente in base al parentType!
|
||||
// FISCHIO SALVAVITA PER DESKTOP: se i bytes sono nulli, li leggiamo dal path fisico!
|
||||
Uint8List? rawBytes = file.bytes;
|
||||
if (rawBytes == null && file.path != null) {
|
||||
rawBytes = File(file.path!).readAsBytesSync();
|
||||
}
|
||||
|
||||
return AttachmentModel(
|
||||
id: null,
|
||||
companyId: companyId!,
|
||||
companyId: currentCompanyId,
|
||||
operationId: state.parentType == AttachmentParentType.operation
|
||||
? ''
|
||||
: null,
|
||||
@@ -134,7 +155,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
||||
extension: file.name.fileExtension(),
|
||||
storagePath: '',
|
||||
fileSize: file.size,
|
||||
localBytes: file.bytes,
|
||||
localBytes: rawBytes, // Ora i byte ci sono al 100% anche su Mac!
|
||||
);
|
||||
}).toList();
|
||||
|
||||
@@ -155,7 +176,8 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
||||
parentId: currentId,
|
||||
parentType: state.parentType,
|
||||
pickedFile: file,
|
||||
companyId: companyId!,
|
||||
companyId: currentCompanyId,
|
||||
bucket: _getBucketForParentType,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
@@ -192,6 +214,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
||||
parentType: state.parentType,
|
||||
pickedFile: file,
|
||||
companyId: event.companyId,
|
||||
bucket: _getBucketForParentType,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -218,6 +241,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
||||
parentType: state.parentType,
|
||||
pickedFile: fakePlatformFile,
|
||||
companyId: event.companyId,
|
||||
bucket: _getBucketForParentType,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -242,6 +266,7 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
||||
await _repository.deleteFiles(
|
||||
files: state.selectedFiles,
|
||||
currentContextType: state.parentType,
|
||||
bucket: _getBucketForParentType,
|
||||
);
|
||||
emit(state.copyWith(status: AttachmentsStatus.ready, selectedFiles: []));
|
||||
} catch (e) {
|
||||
@@ -298,6 +323,10 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
||||
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;
|
||||
@@ -386,4 +415,19 @@ class AttachmentsBloc extends Bloc<AttachmentsEvent, AttachmentsState> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ enum AttachmentsStatus { initial, loading, ready, uploading, success, failure }
|
||||
enum AttachmentParentType {
|
||||
operation('operation_id'),
|
||||
ticket('ticket_id'),
|
||||
customer('customer_id');
|
||||
customer('customer_id'),
|
||||
shippingDocument('shipping_document_id'),
|
||||
note('note_id');
|
||||
|
||||
final String dbColumn;
|
||||
const AttachmentParentType(this.dbColumn);
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
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 _bucketName = 'documents';
|
||||
static const String _tableName =
|
||||
'attachment'; // Cambia col vero nome della tua tabella se diverso!
|
||||
|
||||
/// Scarica i byte di un file direttamente da Supabase Storage
|
||||
Future<Uint8List> downloadAttachmentBytes(String storagePath) async {
|
||||
Future<Uint8List> downloadAttachmentBytes({
|
||||
required String storagePath,
|
||||
required Bucket bucket,
|
||||
}) async {
|
||||
try {
|
||||
final Uint8List bytes = await _supabase.storage
|
||||
.from(_bucketName)
|
||||
.from(bucket.value)
|
||||
.download(storagePath);
|
||||
return bytes;
|
||||
} catch (e) {
|
||||
@@ -31,6 +40,10 @@ class AttachmentsRepository {
|
||||
return 'ticket_id';
|
||||
case AttachmentParentType.customer:
|
||||
return 'customer_id';
|
||||
case AttachmentParentType.shippingDocument:
|
||||
return 'shipping_document_id';
|
||||
case AttachmentParentType.note:
|
||||
return 'note_id';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +55,7 @@ class AttachmentsRepository {
|
||||
final columnName = _getColumnNameForParent(parentType);
|
||||
|
||||
return _supabase
|
||||
.from(_tableName)
|
||||
.from(Tables.attachments)
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq(columnName, parentId)
|
||||
.map(
|
||||
@@ -55,41 +68,70 @@ class AttachmentsRepository {
|
||||
Future<void> uploadAndRegisterFile({
|
||||
required String parentId,
|
||||
required AttachmentParentType parentType,
|
||||
required PlatformFile pickedFile,
|
||||
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
|
||||
}
|
||||
|
||||
final extension = pickedFile.extension ?? pickedFile.name.split('.').last;
|
||||
final cleanName = pickedFile.name
|
||||
// 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(' ', '_');
|
||||
|
||||
// Creiamo un path ordinato: idAzienda/tipoEntita/idEntita/timestamp_nomefile
|
||||
// 3. Creiamo un path ordinato: idAzienda/tipoEntita/idEntita/timestamp_nomefile
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final storagePath =
|
||||
'$companyId/${parentType.name}/$parentId/${timestamp}_$cleanName';
|
||||
|
||||
// 1. Upload su Supabase Storage
|
||||
// 4. Upload su Supabase Storage
|
||||
await _supabase.storage
|
||||
.from(_bucketName)
|
||||
.from(bucket.value)
|
||||
.uploadBinary(
|
||||
storagePath,
|
||||
pickedFile.bytes!,
|
||||
finalBytes,
|
||||
fileOptions: FileOptions(contentType: _guessContentType(extension)),
|
||||
);
|
||||
|
||||
// 2. Creiamo la mappa per il DB dinamicamente
|
||||
// 5. Creiamo la mappa per il DB dinamicamente
|
||||
final Map<String, dynamic> insertData = {
|
||||
'company_id': companyId,
|
||||
'name': pickedFile.name.replaceAll('.$extension', ''),
|
||||
'name': finalFileName.replaceAll('.$extension', ''),
|
||||
'extension': extension,
|
||||
'file_size': pickedFile.size,
|
||||
'file_size': finalFileSize,
|
||||
'storage_path': storagePath,
|
||||
};
|
||||
|
||||
@@ -97,8 +139,8 @@ class AttachmentsRepository {
|
||||
final columnName = _getColumnNameForParent(parentType);
|
||||
insertData[columnName] = parentId;
|
||||
|
||||
// 3. Salviamo su Postgres
|
||||
await _supabase.from(_tableName).insert(insertData);
|
||||
// 6. Salviamo su Postgres
|
||||
await _supabase.from(Tables.attachments).insert(insertData);
|
||||
} catch (e) {
|
||||
throw Exception("Errore caricamento: $e");
|
||||
}
|
||||
@@ -108,6 +150,7 @@ class AttachmentsRepository {
|
||||
Future<void> deleteFiles({
|
||||
required List<AttachmentModel> files,
|
||||
required AttachmentParentType currentContextType,
|
||||
required Bucket bucket,
|
||||
}) async {
|
||||
if (files.isEmpty) return;
|
||||
|
||||
@@ -120,6 +163,7 @@ class AttachmentsRepository {
|
||||
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
|
||||
@@ -134,15 +178,15 @@ class AttachmentsRepository {
|
||||
// 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)
|
||||
.from(Tables.attachments)
|
||||
.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!);
|
||||
await _supabase.from(Tables.attachments).delete().eq('id', file.id!);
|
||||
|
||||
if (file.storagePath != null) {
|
||||
await _supabase.storage.from(_bucketName).remove([
|
||||
await _supabase.storage.from(bucket.value).remove([
|
||||
file.storagePath!,
|
||||
]);
|
||||
}
|
||||
@@ -157,7 +201,7 @@ class AttachmentsRepository {
|
||||
Future<void> renameAttachment(String fileId, String newName) async {
|
||||
try {
|
||||
await _supabase
|
||||
.from(_tableName)
|
||||
.from(Tables.attachments)
|
||||
.update({'name': newName})
|
||||
.eq('id', fileId);
|
||||
} catch (e) {
|
||||
@@ -174,7 +218,7 @@ class AttachmentsRepository {
|
||||
try {
|
||||
// Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta
|
||||
await _supabase
|
||||
.from(_tableName)
|
||||
.from(Tables.attachments)
|
||||
.update({targetType.dbColumn: targetId})
|
||||
.eq('id', fileId);
|
||||
} catch (e) {
|
||||
|
||||
@@ -8,6 +8,8 @@ class AttachmentModel extends Equatable {
|
||||
final String? customerId;
|
||||
final String? operationId;
|
||||
final String? ticketId;
|
||||
final String? shippingDocumentId;
|
||||
final String? noteId;
|
||||
final String name;
|
||||
final String extension;
|
||||
final String? storagePath;
|
||||
@@ -21,6 +23,8 @@ class AttachmentModel extends Equatable {
|
||||
this.customerId,
|
||||
this.operationId,
|
||||
this.ticketId,
|
||||
this.shippingDocumentId,
|
||||
this.noteId,
|
||||
required this.name,
|
||||
required this.extension,
|
||||
this.storagePath,
|
||||
@@ -36,6 +40,8 @@ class AttachmentModel extends Equatable {
|
||||
customerId,
|
||||
operationId,
|
||||
ticketId,
|
||||
shippingDocumentId,
|
||||
noteId,
|
||||
name,
|
||||
extension,
|
||||
storagePath,
|
||||
@@ -63,6 +69,8 @@ class AttachmentModel extends Equatable {
|
||||
String? customerId,
|
||||
String? operationId,
|
||||
String? ticketId,
|
||||
String? shippingDocumentId,
|
||||
String? noteId,
|
||||
String? name,
|
||||
String? extension,
|
||||
String? storagePath,
|
||||
@@ -75,6 +83,8 @@ class AttachmentModel extends Equatable {
|
||||
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,
|
||||
@@ -92,6 +102,8 @@ class AttachmentModel extends Equatable {
|
||||
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?,
|
||||
@@ -111,6 +123,8 @@ class AttachmentModel extends Equatable {
|
||||
'customer_id': customerId,
|
||||
'operation_id': operationId,
|
||||
'ticket_id': ticketId,
|
||||
'shipping_document_id': shippingDocumentId,
|
||||
'note_id': noteId,
|
||||
'file_size': fileSize,
|
||||
'company_id': companyId,
|
||||
};
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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/data/constants.dart';
|
||||
import 'package:flux/core/utils/app_message.dart';
|
||||
import 'package:flux/features/master_data/staff/data/staff_repository.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
part 'auth_state.dart';
|
||||
|
||||
class AuthCubit extends Cubit<AuthState> {
|
||||
final _supabase = GetIt.instance<SupabaseClient>();
|
||||
final _staffRepository = GetIt.instance<StaffRepository>();
|
||||
|
||||
AuthCubit() : super(const AuthState());
|
||||
|
||||
@@ -16,7 +17,8 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
emit(state.copyWith(isLoginMode: !state.isLoginMode));
|
||||
}
|
||||
|
||||
Future<void> submitAuth(String email, String password) async {
|
||||
Future<bool> submitAuth(String email, String password) async {
|
||||
// <-- Modificato in bool
|
||||
// Partiamo puliti: via vecchi messaggi ed errori
|
||||
emit(state.copyWith(status: AuthStatus.loading));
|
||||
|
||||
@@ -27,9 +29,17 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
// NESSUN EMIT DI SUCCESS!
|
||||
// Supabase lancerà l'evento 'signedIn', il SessionCubit lo catturerà
|
||||
// e il GoRouter ci cambierà pagina. Noi stiamo a guardare il caricamento.
|
||||
|
||||
// Il login è andato a buon fine!
|
||||
emit(
|
||||
AuthState(
|
||||
status: AuthStatus.initial,
|
||||
isLoginMode: true,
|
||||
errorMessage: null,
|
||||
infoMessage: null,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
// --- LOGICA SIGNUP ---
|
||||
final AuthResponse res = await _supabase.auth.signUp(
|
||||
@@ -38,7 +48,6 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
);
|
||||
|
||||
if (res.session == null) {
|
||||
// Caso: Conferma Email attivata su Supabase
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AuthStatus.initial,
|
||||
@@ -48,16 +57,24 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Caso: Autologin post-registrazione (Conferma email disattivata)
|
||||
// 1. Fermiamo il frullino!
|
||||
emit(state.copyWith(status: AuthStatus.initial));
|
||||
// 2. Svegliamo il SessionCubit!
|
||||
GetIt.I<SessionCubit>().initializeSession();
|
||||
}
|
||||
// Se non è null, ha fatto il login automatico. Stessa cosa di sopra, ci pensa il SessionCubit.
|
||||
|
||||
// Anche la registrazione è andata a buon fine!
|
||||
emit(
|
||||
AuthState(
|
||||
status: AuthStatus.initial,
|
||||
isLoginMode: true,
|
||||
errorMessage: null,
|
||||
infoMessage: null,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} on AuthException catch (e) {
|
||||
emit(state.copyWith(status: AuthStatus.failure, errorMessage: e.message));
|
||||
return false; // <-- Il login è fallito
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -65,6 +82,7 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
errorMessage: "Errore imprevisto: $e",
|
||||
),
|
||||
);
|
||||
return false; // <-- Il login è fallito
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,10 +96,7 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _supabase.auth.resetPasswordForEmail(
|
||||
email,
|
||||
redirectTo: resetPasswordUrl,
|
||||
);
|
||||
await _staffRepository.resetPassword(email);
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AuthStatus.pwResetSent,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
@@ -24,14 +25,18 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
void _submit() async {
|
||||
// Chiudiamo la tastiera per fare pulizia a schermo
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
context.read<AuthCubit>().submitAuth(
|
||||
final isSuccess = await context.read<AuthCubit>().submitAuth(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text.trim(),
|
||||
);
|
||||
|
||||
if (isSuccess) {
|
||||
TextInput.finishAutofillContext();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -69,6 +74,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: AutofillGroup(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -106,6 +112,10 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
icon: Icons.email_outlined,
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [
|
||||
AutofillHints.email,
|
||||
AutofillHints.username,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FluxTextField(
|
||||
@@ -113,10 +123,35 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
icon: Icons.lock_outline,
|
||||
isPassword: true, // Magia del FluxTextField!
|
||||
controller: _passwordController,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
onSubmitted: (_) =>
|
||||
_submit(), // Se lo supporti nel tuo widget custom
|
||||
),
|
||||
|
||||
// Link "Dimenticato password?" solo in Login mode
|
||||
if (state.isLoginMode) ...[
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => context
|
||||
.read<AuthCubit>()
|
||||
.requestPasswordReset(
|
||||
_emailController.text.trim(),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.authScreenForgotPassword,
|
||||
style: TextStyle(
|
||||
color: context.accent,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// --- BOTTONE PRINCIPALE ---
|
||||
@@ -175,9 +210,10 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
if (state.isLoginMode) ...[
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
onPressed: () => context
|
||||
.read<AuthCubit>()
|
||||
.requestPasswordReset(_emailController.text.trim()),
|
||||
onPressed: () =>
|
||||
context.read<AuthCubit>().requestPasswordReset(
|
||||
_emailController.text.trim(),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.authScreenForgotPassword,
|
||||
style: TextStyle(
|
||||
@@ -191,6 +227,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
255
lib/features/auth/ui/set_password_screen.dart
Normal file
@@ -0,0 +1,255 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/main.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class SetPasswordScreen extends StatefulWidget {
|
||||
const SetPasswordScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SetPasswordScreen> createState() => _SetPasswordScreenState();
|
||||
}
|
||||
|
||||
class _SetPasswordScreenState extends State<SetPasswordScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
String? _errorMessage;
|
||||
|
||||
// Variabile per abilitare l'inserimento
|
||||
bool _isSessionReady = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_forceSessionRecovery();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 🎯 LA VERA MAGIA: RICOSTRUIAMO LA SESSIONE A MANO
|
||||
Future<void> _forceSessionRecovery() async {
|
||||
try {
|
||||
// 1. Prendiamo il frammento dalla cassaforte
|
||||
final fragment = initialRecoveryFragment ?? Uri.base.fragment;
|
||||
|
||||
if (fragment.contains('access_token=')) {
|
||||
// 2. Dividiamo la stringa in una mappa chiave:valore
|
||||
final params = Uri.splitQueryString(fragment);
|
||||
final refreshToken = params['refresh_token'];
|
||||
|
||||
if (refreshToken != null) {
|
||||
// 3. Forziamo Supabase a loggare l'utente col refresh token!
|
||||
await Supabase.instance.client.auth.setSession(refreshToken);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSessionReady = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: se Supabase ce l'aveva già fatta miracolosamente
|
||||
if (Supabase.instance.client.auth.currentSession != null) {
|
||||
setState(() => _isSessionReady = true);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Errore ripristino manuale sessione: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitNewPassword() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
if (!_isSessionReady) {
|
||||
setState(() {
|
||||
_errorMessage =
|
||||
"Sincronizzazione di sicurezza fallita. Il link potrebbe essere scaduto.";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Ora questo updateUser troverà la sessione viva e vegeta!
|
||||
await Supabase.instance.client.auth.updateUser(
|
||||
UserAttributes(password: _passwordController.text.trim()),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Password impostata con successo! Benvenuto in FLUX.',
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
context.go('/');
|
||||
}
|
||||
} on AuthException catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = e.message;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = "Si è verificato un errore imprevisto. Riprova.";
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Rendiamo la schermata responsive ed elegante per il Web (Cloudflare)
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isWebContainer = size.width > 600;
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Container(
|
||||
width: isWebContainer ? 450 : size.width,
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Logo o Brand FLUX
|
||||
Text(
|
||||
'FLUX',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Configura la tua chiave di accesso per iniziare a collaborare.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
if (_errorMessage != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Campo Password
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nuova Password',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _obscurePassword = !_obscurePassword,
|
||||
),
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Inserisci una password';
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return 'La password deve avere almeno 6 caratteri';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Campo Conferma Password
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Conferma Password',
|
||||
prefixIcon: Icon(Icons.lock_reset_rounded),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != _passwordController.text) {
|
||||
return 'Le password non coincidono';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Bottone di Invio
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _submitNewPassword,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Conferma e Accedi',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +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';
|
||||
|
||||
@@ -10,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();
|
||||
@@ -26,7 +27,7 @@ class CompanyRepository {
|
||||
Future<CompanyModel> updateCompany(CompanyModel company) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('company')
|
||||
.from(Tables.companies)
|
||||
.update(company.toMap())
|
||||
.eq('id', company.id!)
|
||||
.select()
|
||||
@@ -83,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();
|
||||
|
||||
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];
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import 'dart:async'; // Serve per il Timer del debounce
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/customers/data/customer_repository.dart';
|
||||
import 'package:flux/features/customers/models/customer_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
part 'customers_state.dart';
|
||||
|
||||
class CustomersCubit extends Cubit<CustomersState> {
|
||||
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
|
||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||
|
||||
// Variabile per gestire il debounce della ricerca
|
||||
Timer? _searchDebounce;
|
||||
|
||||
CustomersCubit() : super(const CustomersState());
|
||||
|
||||
// --- LETTURA ---
|
||||
Future<void> loadCustomers() async {
|
||||
emit(state.copyWith(status: CustomersStatus.loading));
|
||||
try {
|
||||
final customers = await _repository.getCustomers(
|
||||
_sessionCubit.state.company!.id!,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(status: CustomersStatus.success, customers: customers),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomersStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- CREAZIONE ---
|
||||
Future<void> createCustomer(CustomerModel customer) async {
|
||||
emit(state.copyWith(status: CustomersStatus.loading));
|
||||
try {
|
||||
final newCustomer = await _repository.saveCustomer(customer);
|
||||
|
||||
// Aggiorniamo la lista locale aggiungendo il nuovo cliente in cima
|
||||
final updatedList = List<CustomerModel>.from(state.customers)
|
||||
..insert(0, newCustomer);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomersStatus.success,
|
||||
customers: updatedList,
|
||||
lastCreatedCustomer: newCustomer,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomersStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- AGGIORNAMENTO ---
|
||||
Future<void> updateCustomer(CustomerModel customer) async {
|
||||
emit(state.copyWith(status: CustomersStatus.loading));
|
||||
try {
|
||||
final updatedCustomer = await _repository.updateCustomer(customer);
|
||||
|
||||
final updatedList = List<CustomerModel>.from(state.customers);
|
||||
final index = updatedList.indexWhere((c) => c.id == updatedCustomer.id);
|
||||
|
||||
if (index != -1) {
|
||||
updatedList[index] = updatedCustomer;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomersStatus.success,
|
||||
customers: updatedList,
|
||||
lastCreatedCustomer:
|
||||
updatedCustomer, // Utile se modifichi un cliente appena creato
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomersStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- RICERCA CON DEBOUNCE ---
|
||||
void searchCustomers(String query) {
|
||||
// 1. Se c'è già una ricerca in attesa (l'utente sta digitando veloce), la annulliamo
|
||||
if (_searchDebounce?.isActive ?? false) _searchDebounce!.cancel();
|
||||
|
||||
// 2. Facciamo partire un timer di 400 millisecondi
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 300), () async {
|
||||
// Se cancella tutto e la query è vuota, ricarichiamo la lista base
|
||||
if (query.trim().isEmpty) {
|
||||
await loadCustomers();
|
||||
return;
|
||||
}
|
||||
|
||||
// Nessun "loading" state qui, per evitare sfarfallii visivi mentre si scrive
|
||||
try {
|
||||
final results = await _repository.searchCustomers(
|
||||
_sessionCubit.state.company!.id!,
|
||||
query,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(status: CustomersStatus.success, customers: results),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomersStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<CustomerModel?> quickCreateCustomer({
|
||||
required String name,
|
||||
String? phone,
|
||||
String? email,
|
||||
}) async {
|
||||
final newCustomer = CustomerModel(
|
||||
name: name,
|
||||
phoneNumber: phone ?? '',
|
||||
email: email ?? '',
|
||||
companyId: _sessionCubit.state.company!.id!,
|
||||
note: '',
|
||||
);
|
||||
|
||||
try {
|
||||
final saved = await _repository.saveCustomer(newCustomer);
|
||||
// Lo aggiungiamo in cima ai suggerimenti
|
||||
emit(state.copyWith(customers: [saved, ...state.customers]));
|
||||
return saved;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Pulizia della memoria quando il Cubit viene distrutto
|
||||
@override
|
||||
Future<void> close() {
|
||||
_searchDebounce?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
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 'customers_cubit.dart';
|
||||
part of 'customers_list_cubit.dart';
|
||||
|
||||
enum CustomersStatus {
|
||||
enum CustomersListStatus {
|
||||
initial,
|
||||
loading,
|
||||
filesLoading,
|
||||
@@ -9,26 +9,26 @@ enum CustomersStatus {
|
||||
failure,
|
||||
}
|
||||
|
||||
class CustomersState extends Equatable {
|
||||
final CustomersStatus status;
|
||||
class CustomersListState extends Equatable {
|
||||
final CustomersListStatus status;
|
||||
final List<CustomerModel> customers;
|
||||
final CustomerModel? lastCreatedCustomer;
|
||||
final String? errorMessage;
|
||||
|
||||
const CustomersState({
|
||||
this.status = CustomersStatus.initial,
|
||||
const CustomersListState({
|
||||
this.status = CustomersListStatus.initial,
|
||||
this.customers = const [],
|
||||
this.lastCreatedCustomer,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
CustomersState copyWith({
|
||||
CustomersStatus? status,
|
||||
CustomersListState copyWith({
|
||||
CustomersListStatus? status,
|
||||
List<CustomerModel>? customers,
|
||||
CustomerModel? lastCreatedCustomer,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return CustomersState(
|
||||
return CustomersListState(
|
||||
status: status ?? this.status,
|
||||
customers: customers ?? this.customers,
|
||||
lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer,
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
@@ -11,10 +12,10 @@ 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();
|
||||
@@ -27,7 +28,7 @@ class CustomerRepository {
|
||||
Future<CustomerModel> updateCustomer(CustomerModel customer) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('customer')
|
||||
.from(Tables.customers)
|
||||
.update(customer.toJson())
|
||||
.eq('id', customer.id!)
|
||||
.select()
|
||||
@@ -42,14 +43,14 @@ class CustomerRepository {
|
||||
Future<List<CustomerModel>> getCustomers(String companyId) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('customer')
|
||||
.from(Tables.customers)
|
||||
.select('''
|
||||
*,
|
||||
attachment(*)
|
||||
${Tables.attachments}(*)
|
||||
''')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('name');
|
||||
.order('name', ascending: true);
|
||||
|
||||
return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
|
||||
} catch (e) {
|
||||
@@ -57,6 +58,23 @@ class CustomerRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<CustomerModel> getCustomerById(String customerId) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from(Tables.customers)
|
||||
.select('''
|
||||
*,
|
||||
${Tables.attachments}(*)
|
||||
''')
|
||||
.eq('id', customerId)
|
||||
.single();
|
||||
|
||||
return CustomerModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
// Ricerca clienti per nome o telefono (fondamentale per la UX)
|
||||
Future<List<CustomerModel>> searchCustomers(
|
||||
String companyId,
|
||||
@@ -64,7 +82,7 @@ class CustomerRepository {
|
||||
) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('customer')
|
||||
.from(Tables.customers)
|
||||
.select()
|
||||
.eq('company_id', companyId)
|
||||
.or('name.ilike.%$query%,phone_number.ilike.%$query%')
|
||||
@@ -79,7 +97,7 @@ class CustomerRepository {
|
||||
/// Ascolta in tempo reale i file caricati per un cliente
|
||||
Stream<List<AttachmentModel>> getCustomerFilesStream(String customerId) {
|
||||
return _supabase
|
||||
.from('attachment')
|
||||
.from(Tables.attachments)
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('customer_id', customerId)
|
||||
.order('created_at', ascending: false)
|
||||
@@ -93,7 +111,7 @@ class CustomerRepository {
|
||||
Future<List<AttachmentModel>> getCustomerFiles(String customerId) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('attachment')
|
||||
.from(Tables.attachments)
|
||||
.select()
|
||||
.eq('customer_id', customerId);
|
||||
|
||||
@@ -144,7 +162,7 @@ class CustomerRepository {
|
||||
}
|
||||
|
||||
final response = await _supabase
|
||||
.from('attachment')
|
||||
.from(Tables.attachments)
|
||||
.insert(fileToSave.toMap())
|
||||
.select()
|
||||
.single();
|
||||
@@ -156,7 +174,7 @@ class CustomerRepository {
|
||||
}
|
||||
|
||||
Future<void> saveFileReference(AttachmentModel file) async {
|
||||
await _supabase.from('attachment').upsert(file.toMap());
|
||||
await _supabase.from(Tables.attachments).upsert(file.toMap());
|
||||
}
|
||||
|
||||
Future<void> deleteDocuments(List<AttachmentModel> files) async {
|
||||
@@ -175,13 +193,16 @@ class CustomerRepository {
|
||||
}
|
||||
try {
|
||||
if (idsToDelete.isNotEmpty) {
|
||||
await _supabase.from('attachment').delete().inFilter('id', idsToDelete);
|
||||
await _supabase
|
||||
.from(Tables.attachments)
|
||||
.delete()
|
||||
.inFilter('id', idsToDelete);
|
||||
// 3. Cancellazione MASSIVA dallo Storage
|
||||
await _supabase.storage.from('documents').remove(storagePathsToDelete);
|
||||
}
|
||||
if (idsToEdit.isNotEmpty) {
|
||||
await _supabase
|
||||
.from('attachment')
|
||||
.from(Tables.attachments)
|
||||
.update({'customer_id': null})
|
||||
.inFilter('id', idsToEdit);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class CustomerModel extends Equatable {
|
||||
final String companyId; // UUID
|
||||
final bool isActive;
|
||||
final List<AttachmentModel> attachments;
|
||||
final bool isBusiness;
|
||||
|
||||
const CustomerModel({
|
||||
this.id,
|
||||
@@ -27,6 +28,7 @@ class CustomerModel extends Equatable {
|
||||
required this.companyId,
|
||||
this.isActive = true,
|
||||
this.attachments = const [],
|
||||
this.isBusiness = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -42,8 +44,18 @@ class CustomerModel extends Equatable {
|
||||
companyId,
|
||||
isActive,
|
||||
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,
|
||||
@@ -56,6 +68,7 @@ class CustomerModel extends Equatable {
|
||||
String? companyId,
|
||||
bool? isActive,
|
||||
List<AttachmentModel>? attachments,
|
||||
bool? isBusiness,
|
||||
}) {
|
||||
return CustomerModel(
|
||||
id: id ?? this.id,
|
||||
@@ -69,6 +82,7 @@ class CustomerModel extends Equatable {
|
||||
companyId: companyId ?? this.companyId,
|
||||
isActive: isActive ?? this.isActive,
|
||||
attachments: attachments ?? this.attachments,
|
||||
isBusiness: isBusiness ?? this.isBusiness,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,6 +107,7 @@ class CustomerModel extends Equatable {
|
||||
?.map((x) => AttachmentModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
isBusiness: map['is_business'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,6 +123,7 @@ class CustomerModel extends Equatable {
|
||||
'do_not_disturb': doNotDisturb,
|
||||
'company_id': companyId,
|
||||
'is_active': isActive,
|
||||
'is_business': isBusiness,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/widgets/flux_text_field.dart';
|
||||
import 'package:flux/features/customers/models/customer_model.dart'; // Uso il tuo widget!
|
||||
|
||||
class CustomerForm extends StatefulWidget {
|
||||
final CustomerModel? customer; // Se presente, siamo in modalità "Modifica"
|
||||
final Function(CustomerModel customer) onSave;
|
||||
|
||||
const CustomerForm({
|
||||
super.key,
|
||||
this.customer, // Opzionale
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CustomerForm> createState() => _CustomerFormState();
|
||||
}
|
||||
|
||||
class _CustomerFormState extends State<CustomerForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controller inizializzati con i dati del cliente (se presenti)
|
||||
late final TextEditingController _nomeController;
|
||||
late final TextEditingController _telefonoController;
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _noteController;
|
||||
late bool _nonDisturbare;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Se widget.customer è null, i campi saranno vuoti
|
||||
_nomeController = TextEditingController(text: widget.customer?.name ?? '');
|
||||
_telefonoController = TextEditingController(
|
||||
text: widget.customer?.phoneNumber ?? '',
|
||||
);
|
||||
_emailController = TextEditingController(
|
||||
text: widget.customer?.email ?? '',
|
||||
);
|
||||
_noteController = TextEditingController(text: widget.customer?.note ?? '');
|
||||
_nonDisturbare = widget.customer?.doNotDisturb ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nomeController.dispose();
|
||||
_telefonoController.dispose();
|
||||
_emailController.dispose();
|
||||
_noteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Creiamo un nuovo modello partendo da quello esistente (se c'è)
|
||||
// o creandone uno da zero, preservando l'ID in caso di modifica.
|
||||
final updatedCustomer =
|
||||
widget.customer?.copyWith(
|
||||
name: _nomeController.text.trim(),
|
||||
phoneNumber: _telefonoController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
note: _noteController.text.trim(),
|
||||
doNotDisturb: _nonDisturbare,
|
||||
) ??
|
||||
CustomerModel(
|
||||
// Caso nuovo cliente
|
||||
name: _nomeController.text.trim(),
|
||||
phoneNumber: _telefonoController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
note: _noteController.text.trim(),
|
||||
doNotDisturb: _nonDisturbare,
|
||||
companyId: '', // Verrà iniettato dal Bloc o dal chiamante
|
||||
);
|
||||
|
||||
widget.onSave(updatedCustomer);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.customer == null ? 'Nuovo Cliente' : 'Modifica Cliente',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FluxTextField(
|
||||
label: 'Nome Completo',
|
||||
autoFocus: true,
|
||||
icon: Icons.person_outline,
|
||||
controller: _nomeController,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FluxTextField(
|
||||
label: 'Telefono',
|
||||
icon: Icons.phone_android_outlined,
|
||||
controller: _telefonoController,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FluxTextField(
|
||||
label: 'Email',
|
||||
icon: Icons.alternate_email_outlined,
|
||||
controller: _emailController,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FluxTextField(
|
||||
label: 'Note',
|
||||
icon: Icons.notes_outlined,
|
||||
controller: _noteController,
|
||||
minLines: 3,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
title: const Text('Non disturbare'),
|
||||
value: _nonDisturbare,
|
||||
onChanged: (v) => setState(() => _nonDisturbare = v),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: _submit,
|
||||
child: Text(widget.customer == null ? 'SALVA' : 'AGGIORNA'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,18 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/routes/routes.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
||||
import 'package:flux/features/customers/blocs/customers_list_cubit.dart';
|
||||
import 'package:flux/features/customers/models/customer_model.dart';
|
||||
import 'package:flux/features/customers/ui/customer_form.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class CustomersContent extends StatefulWidget {
|
||||
const CustomersContent({super.key});
|
||||
class CustomersListScreen extends StatefulWidget {
|
||||
const CustomersListScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CustomersContent> createState() => _CustomersContentState();
|
||||
State<CustomersListScreen> createState() => _CustomersListScreenState();
|
||||
}
|
||||
|
||||
class _CustomersContentState extends State<CustomersContent> {
|
||||
class _CustomersListScreenState extends State<CustomersListScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
@@ -27,14 +26,14 @@ class _CustomersContentState extends State<CustomersContent> {
|
||||
void _loadInitialCustomers() {
|
||||
final companyId = context.read<SessionCubit>().state.company?.id;
|
||||
if (companyId != null) {
|
||||
context.read<CustomersCubit>().loadCustomers();
|
||||
context.read<CustomersListCubit>().loadCustomers();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearch(String query) {
|
||||
final companyId = context.read<SessionCubit>().state.company?.id;
|
||||
if (companyId != null) {
|
||||
context.read<CustomersCubit>().searchCustomers(query);
|
||||
context.read<CustomersListCubit>().searchCustomers(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +52,12 @@ class _CustomersContentState extends State<CustomersContent> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => openCustomerForm(context: context),
|
||||
onPressed: () {
|
||||
context.pushNamed(
|
||||
Routes.customerForm,
|
||||
pathParameters: {'id': 'new'},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.person_add_alt_1_rounded, size: 20),
|
||||
label: const Text('NUOVO'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -87,9 +91,9 @@ class _CustomersContentState extends State<CustomersContent> {
|
||||
|
||||
// LISTA CLIENTI
|
||||
Expanded(
|
||||
child: BlocBuilder<CustomersCubit, CustomersState>(
|
||||
child: BlocBuilder<CustomersListCubit, CustomersListState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == CustomersStatus.loading &&
|
||||
if (state.status == CustomersListStatus.loading &&
|
||||
state.customers.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@@ -111,7 +115,7 @@ class _CustomersContentState extends State<CustomersContent> {
|
||||
return _CustomerTile(
|
||||
customer: customer,
|
||||
onTap: () => context.pushNamed(
|
||||
Routes.customerForm,
|
||||
Routes.customerDetails,
|
||||
pathParameters: {'id': customer.id!},
|
||||
extra: customer,
|
||||
),
|
||||
@@ -214,8 +218,16 @@ class _CustomerTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
trailing: IconButton(
|
||||
onPressed: () =>
|
||||
openCustomerForm(context: context, customer: customer),
|
||||
onPressed: () async {
|
||||
final CustomersListCubit customersCubit = context
|
||||
.read<CustomersListCubit>();
|
||||
await context.pushNamed(
|
||||
Routes.customerForm,
|
||||
pathParameters: {'id': customer.id!},
|
||||
extra: customer,
|
||||
);
|
||||
customersCubit.loadCustomers();
|
||||
},
|
||||
icon: Icon(Icons.edit_note_rounded, color: context.accent),
|
||||
),
|
||||
),
|
||||
@@ -224,7 +236,7 @@ class _CustomerTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// Funzione unica per gestire Creazione e Modifica
|
||||
void openCustomerForm({
|
||||
/* void openCustomerForm({
|
||||
CustomerModel? customer,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
@@ -257,4 +269,4 @@ void openCustomerForm({
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} */
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
||||
import 'package:flux/features/customers/blocs/customer_form_cubit.dart';
|
||||
|
||||
class QuickCustomerDialog extends StatefulWidget {
|
||||
final String initialQuery;
|
||||
@@ -16,6 +16,7 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
|
||||
final _phoneCtrl = TextEditingController();
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _noteCtrl = TextEditingController();
|
||||
bool _isBusiness = false; // Aggiungiamo un campo per il tipo di cliente
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
@@ -43,13 +44,12 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
|
||||
|
||||
// Chiamata al Cubit (aggiorna i parametri in base a come li hai definiti)
|
||||
final newCustomer = await context
|
||||
.read<CustomersCubit>()
|
||||
.read<CustomerFormCubit>()
|
||||
.quickCreateCustomer(
|
||||
isBusiness: _isBusiness,
|
||||
name: _nameCtrl.text.trim(),
|
||||
phone: _phoneCtrl.text.trim(),
|
||||
// Aggiungi questi se li hai inseriti nel tuo CustomerCubit:
|
||||
// email: _emailCtrl.text.trim(),
|
||||
// note: _noteCtrl.text.trim(),
|
||||
email: _emailCtrl.text.trim(),
|
||||
);
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
@@ -67,6 +67,41 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Privato (Domestico)'),
|
||||
selected: _isBusiness == false,
|
||||
selectedColor: Colors.blue.withValues(alpha: 0.2),
|
||||
checkmarkColor: Colors.blue.shade700,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setState(() {
|
||||
_isBusiness = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ChoiceChip(
|
||||
label: const Text('Business (P.IVA)'),
|
||||
selected: _isBusiness == true,
|
||||
selectedColor: Colors.orange.withValues(alpha: 0.2),
|
||||
checkmarkColor: Colors.orange.shade700,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setState(() {
|
||||
_isBusiness = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
autofocus: true, // Focus immediato!
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/notes/data/notes_repository.dart';
|
||||
import 'package:flux/features/notes/models/note_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
part 'dashboard_note_list_state.dart';
|
||||
|
||||
class DashboardNoteListCubit extends Cubit<DashboardNoteListState> {
|
||||
final NotesRepository _repository = GetIt.I.get<NotesRepository>();
|
||||
final String? companyId;
|
||||
final String? staffId;
|
||||
StreamSubscription<void>? _subscription;
|
||||
|
||||
DashboardNoteListCubit({required this.companyId, required this.staffId})
|
||||
: super(DashboardNoteListState(status: DashboardNoteListStatus.initial));
|
||||
|
||||
void stopListening() {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
}
|
||||
|
||||
void startListening() {
|
||||
stopListening();
|
||||
|
||||
emit(state.copyWith(status: DashboardNoteListStatus.loading));
|
||||
|
||||
// Primo caricamento
|
||||
_loadNotesSilently();
|
||||
|
||||
// Inizio ascolto campanello
|
||||
try {
|
||||
_subscription = _repository
|
||||
.notesStream(companyId: companyId!, currentStaffId: staffId!)
|
||||
.listen((_) {
|
||||
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
|
||||
_loadNotesSilently();
|
||||
});
|
||||
} on Exception catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadNotesSilently() async {
|
||||
try {
|
||||
final notes = await _repository.getNotes();
|
||||
if (isClosed) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: DashboardNoteListStatus.success,
|
||||
notes: notes,
|
||||
errorMessage: null,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (isClosed) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: DashboardNoteListStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
stopListening();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
part of 'dashboard_note_list_cubit.dart';
|
||||
|
||||
enum DashboardNoteListStatus { initial, loading, success, failure }
|
||||
|
||||
class DashboardNoteListState extends Equatable {
|
||||
final DashboardNoteListStatus status;
|
||||
final List<NoteModel> notes;
|
||||
final String? errorMessage;
|
||||
|
||||
const DashboardNoteListState({
|
||||
this.status = DashboardNoteListStatus.initial,
|
||||
this.notes = const [],
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
DashboardNoteListState copyWith({
|
||||
DashboardNoteListStatus? status,
|
||||
List<NoteModel>? notes,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return DashboardNoteListState(
|
||||
status: status ?? this.status,
|
||||
notes: notes ?? this.notes,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, notes, errorMessage];
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/routes/routes.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/features/home/dashboard_note_list/blocs/dashboard_note_list_cubit.dart';
|
||||
import 'package:flux/features/notes/models/note_model.dart';
|
||||
import 'package:go_router/go_router.dart'; // Supponendo tu usi GoRouter per la navigazione
|
||||
|
||||
class DashboardNoteListCard extends StatelessWidget {
|
||||
const DashboardNoteListCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.pushNamed(Routes.notes),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Intestazione del riquadro
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.yellow.shade700.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.sticky_note_2_outlined,
|
||||
size: 16,
|
||||
color: Colors.yellow.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Le mie Note',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: context.primaryText,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Il corpo del widget collegato al Bloc
|
||||
BlocBuilder<DashboardNoteListCubit, DashboardNoteListState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == DashboardNoteListStatus.loading &&
|
||||
state.notes.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state.status == DashboardNoteListStatus.failure) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Errore nel caricamento delle note.',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.notes.isEmpty) {
|
||||
return _buildEmptyState(context);
|
||||
}
|
||||
|
||||
// Prendiamo solo le prime 4 note per non intaccare troppo spazio in Dashboard
|
||||
final displayNotes = state.notes.take(4).toList();
|
||||
|
||||
return SizedBox(
|
||||
height: 140, // Altezza fissa per lo scroll orizzontale
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: displayNotes.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildMiniPostIt(context, displayNotes[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMiniPostIt(BuildContext context, NoteModel note) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// Vai al form di dettaglio passando l'ID o l'oggetto
|
||||
context.pushNamed(
|
||||
Routes.noteForm,
|
||||
pathParameters: {'id': note.id!},
|
||||
extra: note,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 140,
|
||||
margin: const EdgeInsets.only(right: 12, bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: note.flutterColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 4,
|
||||
offset: Offset(2, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
note.title?.isNotEmpty == true
|
||||
? note.title!
|
||||
: 'Senza titolo',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: Colors.black87,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (note.isPinned)
|
||||
const Icon(Icons.push_pin, size: 14, color: Colors.black54),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
note.content ?? '',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.black87),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.sticky_note_2_outlined,
|
||||
size: 32,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Nessuna nota presente.'),
|
||||
TextButton(
|
||||
onPressed: () => context.push('/notes/create'),
|
||||
child: const Text('Creane una ora'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/operations/data/operations_repository.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
part 'dashboard_store_operation_list_state.dart';
|
||||
|
||||
class DashboardStoreOperationListCubit
|
||||
extends Cubit<DashboardStoreOperationListState> {
|
||||
final OperationsRepository _repository = GetIt.I.get<OperationsRepository>();
|
||||
final String? companyId;
|
||||
final String? storeId;
|
||||
StreamSubscription<void>? _operationsSubscription;
|
||||
DashboardStoreOperationListCubit({
|
||||
required this.companyId,
|
||||
required this.storeId,
|
||||
}) : super(
|
||||
const DashboardStoreOperationListState(
|
||||
status: DashboardStoreOperationListStatus.initial,
|
||||
),
|
||||
);
|
||||
|
||||
void startListening() {
|
||||
emit(state.copyWith(status: DashboardStoreOperationListStatus.loading));
|
||||
stopListening();
|
||||
|
||||
// Primo caricamento
|
||||
_loadOperationsSilently();
|
||||
|
||||
// Inizio ascolto campanello
|
||||
try {
|
||||
_operationsSubscription = _repository
|
||||
.watchStoreOperations(storeId: storeId!, limit: 10)
|
||||
.listen((_) {
|
||||
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
|
||||
_loadOperationsSilently();
|
||||
});
|
||||
} on Exception catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void stopListening() {
|
||||
_operationsSubscription?.cancel();
|
||||
_operationsSubscription = null;
|
||||
}
|
||||
|
||||
void _loadOperationsSilently() async {
|
||||
try {
|
||||
final paginatedData = await _repository.fetchPaginatedOperations(
|
||||
companyId: companyId!,
|
||||
storeId: storeId!,
|
||||
page: 1,
|
||||
itemsPerPage: 20,
|
||||
);
|
||||
if (isClosed) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: DashboardStoreOperationListStatus.success,
|
||||
operations: paginatedData.operations,
|
||||
error: null,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (isClosed) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: DashboardStoreOperationListStatus.failure,
|
||||
error: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
stopListening();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
part of 'dashboard_store_operation_list_cubit.dart';
|
||||
|
||||
enum DashboardStoreOperationListStatus { initial, loading, success, failure }
|
||||
|
||||
class DashboardStoreOperationListState extends Equatable {
|
||||
final DashboardStoreOperationListStatus status;
|
||||
final String? error;
|
||||
final List<OperationModel> operations;
|
||||
|
||||
const DashboardStoreOperationListState({
|
||||
required this.status,
|
||||
this.error,
|
||||
this.operations = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, error, operations];
|
||||
|
||||
DashboardStoreOperationListState copyWith({
|
||||
DashboardStoreOperationListStatus? status,
|
||||
String? error,
|
||||
List<OperationModel>? operations,
|
||||
}) {
|
||||
return DashboardStoreOperationListState(
|
||||
status: status ?? this.status,
|
||||
error: error,
|
||||
operations: operations ?? this.operations,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/routes/routes.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart';
|
||||
import 'package:flux/features/home/dashboard_store_operation_list/bloc/dashboard_store_operation_list_cubit.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class LatestStoreOperationsCard extends StatelessWidget {
|
||||
const LatestStoreOperationsCard({super.key});
|
||||
class DashboardStoreOperationListCard extends StatelessWidget {
|
||||
const DashboardStoreOperationListCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentStoreId = context.read<SessionCubit>().state.currentStore?.id;
|
||||
|
||||
return BlocProvider(
|
||||
// 1. Creiamo il Bloc e facciamo partire subito la query
|
||||
create: (context) =>
|
||||
LatestStoreOperationsBloc()
|
||||
..add(InitLastStoreOperationsEvent(currentStoreId ?? '')),
|
||||
child: BlocListener<SessionCubit, SessionState>(
|
||||
// 2. MAGIA: Se l'utente cambia negozio dalla barra in alto, riavviamo lo stream!
|
||||
listenWhen: (previous, current) =>
|
||||
previous.currentStore?.id != current.currentStore?.id,
|
||||
listener: (context, state) {
|
||||
if (state.currentStore?.id != null) {
|
||||
context.read<LatestStoreOperationsBloc>().add(
|
||||
InitLastStoreOperationsEvent(state.currentStore!.id!),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: _LatestOperationsCardContent(),
|
||||
),
|
||||
);
|
||||
return _LatestOperationsCardContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,13 +24,13 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.pushNamed(Routes.operations),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -91,21 +70,21 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
||||
Expanded(
|
||||
child:
|
||||
BlocBuilder<
|
||||
LatestStoreOperationsBloc,
|
||||
LatestStoreOperationsState
|
||||
DashboardStoreOperationListCubit,
|
||||
DashboardStoreOperationListState
|
||||
>(
|
||||
builder: (context, state) {
|
||||
if (state.status ==
|
||||
LatestStoreOperationsStatus.loading ||
|
||||
DashboardStoreOperationListStatus.loading ||
|
||||
state.status ==
|
||||
LatestStoreOperationsStatus.initial) {
|
||||
DashboardStoreOperationListStatus.initial) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status ==
|
||||
LatestStoreOperationsStatus.failure) {
|
||||
DashboardStoreOperationListStatus.failure) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"Errore di caricamento",
|
||||
@@ -137,8 +116,8 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
||||
return InkWell(
|
||||
onTap: () => context.pushNamed(
|
||||
Routes.operationForm,
|
||||
extra: (createdBy: null, operation: operation),
|
||||
pathParameters: {'id': operation.id!},
|
||||
extra: operation,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -162,7 +141,7 @@ class _LatestOperationsCardContent extends StatelessWidget {
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Text(
|
||||
operation.reference,
|
||||
operation.type,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryText,
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/tickets/data/ticket_repository.dart';
|
||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
part 'dashboard_store_ticket_list_state.dart';
|
||||
|
||||
class DashboardStoreTicketListCubit
|
||||
extends Cubit<DashboardStoreTicketListState> {
|
||||
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
|
||||
final String? companyId;
|
||||
final String? storeId;
|
||||
StreamSubscription<void>? _subscription;
|
||||
|
||||
DashboardStoreTicketListCubit({
|
||||
required this.companyId,
|
||||
required this.storeId,
|
||||
}) : super(
|
||||
const DashboardStoreTicketListState(
|
||||
status: DashboardStoreTicketListStatus.initial,
|
||||
),
|
||||
);
|
||||
|
||||
void stopListening() {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
}
|
||||
|
||||
void startListening() {
|
||||
stopListening();
|
||||
|
||||
emit(state.copyWith(status: DashboardStoreTicketListStatus.loading));
|
||||
|
||||
// Primo caricamento
|
||||
_loadTicketsSilently();
|
||||
|
||||
// Inizio ascolto campanello
|
||||
try {
|
||||
_subscription = _repository
|
||||
.getLatestStoreTicketsStream(storeId: storeId!, limit: 10)
|
||||
.listen((_) {
|
||||
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
|
||||
_loadTicketsSilently();
|
||||
});
|
||||
} on Exception catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTicketsSilently() async {
|
||||
try {
|
||||
final tickets = await _repository.fetchTickets(
|
||||
companyId: companyId!,
|
||||
storeId: storeId,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
);
|
||||
if (isClosed) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: DashboardStoreTicketListStatus.success,
|
||||
tickets: tickets,
|
||||
errorMessage: null,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (isClosed) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: DashboardStoreTicketListStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
stopListening();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
part of 'dashboard_store_ticket_list_cubit.dart';
|
||||
|
||||
enum DashboardStoreTicketListStatus { initial, loading, success, failure }
|
||||
|
||||
class DashboardStoreTicketListState extends Equatable {
|
||||
final DashboardStoreTicketListStatus status;
|
||||
final List<TicketModel> tickets;
|
||||
final String? errorMessage;
|
||||
|
||||
const DashboardStoreTicketListState({
|
||||
this.status = DashboardStoreTicketListStatus.initial,
|
||||
this.tickets = const [],
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
DashboardStoreTicketListState copyWith({
|
||||
DashboardStoreTicketListStatus? status,
|
||||
List<TicketModel>? tickets,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return DashboardStoreTicketListState(
|
||||
status: status ?? this.status,
|
||||
tickets: tickets ?? this.tickets,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, tickets, errorMessage];
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/routes/routes.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/features/home/dashboard_store_ticket_list/blocs/dashboard_store_ticket_list_cubit.dart';
|
||||
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class DashboardStoreTicketListCard extends StatelessWidget {
|
||||
const DashboardStoreTicketListCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _DashboardStoreTicketListCardContent();
|
||||
}
|
||||
}
|
||||
|
||||
class _DashboardStoreTicketListCardContent extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
const color = Colors.blue;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.pushNamed(Routes.tickets),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- HEADER DELLA CARD ---
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.design_services_outlined,
|
||||
color: color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Ticket recenti",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: context.primaryText,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
|
||||
Expanded(
|
||||
child:
|
||||
BlocBuilder<
|
||||
DashboardStoreTicketListCubit,
|
||||
DashboardStoreTicketListState
|
||||
>(
|
||||
builder: (context, state) {
|
||||
if (state.status ==
|
||||
DashboardStoreTicketListStatus.loading ||
|
||||
state.status ==
|
||||
DashboardStoreTicketListStatus.initial) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status ==
|
||||
DashboardStoreTicketListStatus.failure) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"Errore di caricamento",
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.tickets.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"Nessun ticket recente.",
|
||||
style: TextStyle(
|
||||
color: context.secondaryText,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: state.tickets.length,
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 1,
|
||||
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final ticket = state.tickets[index];
|
||||
final statusColor = ticket.ticketStatus.color;
|
||||
return InkWell(
|
||||
onTap: () => context.pushNamed(
|
||||
Routes.ticketForm,
|
||||
extra: (createdBy: null, ticket: ticket),
|
||||
pathParameters: {'id': ticket.id!},
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height:
|
||||
30, // Un'altezza fissa per farlo comparire!
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
borderRadius: BorderRadius.circular(
|
||||
4,
|
||||
), // Angoli smussati per stile
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Text(
|
||||
ticket.customer?.name ??
|
||||
'Cliente sconosciuto',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.primaryText,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Text(
|
||||
ticket.targetModelName ??
|
||||
'Modello sconosciuto',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryText,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${ticket.createdAt?.day}/${ticket.createdAt?.month}",
|
||||
style: TextStyle(
|
||||
color: context.secondaryText,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/tasks/data/task_repository.dart';
|
||||
import 'package:flux/features/tasks/models/task_model.dart';
|
||||
import 'package:flux/features/tasks/models/task_status.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
part 'dashboard_task_list_state.dart';
|
||||
|
||||
class DashboardTaskListCubit extends Cubit<DashboardTaskListState> {
|
||||
final TasksRepository _repository = GetIt.I.get<TasksRepository>();
|
||||
final String? staffId;
|
||||
final String? companyId;
|
||||
StreamSubscription<void>? _tasksSubscription;
|
||||
|
||||
DashboardTaskListCubit({required this.staffId, required this.companyId})
|
||||
: super(const DashboardTaskListState());
|
||||
|
||||
void startListening() {
|
||||
stopListening();
|
||||
|
||||
emit(state.copyWith(status: DashboardTaskListStatus.loading));
|
||||
|
||||
// Primo caricamento
|
||||
_loadTasksSilently();
|
||||
|
||||
// Inizio ascolto campanello
|
||||
try {
|
||||
_tasksSubscription = _repository.watchCompanyTasks(companyId!).listen((
|
||||
_,
|
||||
) {
|
||||
// Quando il campanello suona (qualcosa è cambiato a DB), ricarichiamo!
|
||||
_loadTasksSilently();
|
||||
});
|
||||
} on Exception catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void stopListening() {
|
||||
_tasksSubscription?.cancel();
|
||||
_tasksSubscription = null;
|
||||
}
|
||||
|
||||
Future<void> _loadTasksSilently() async {
|
||||
try {
|
||||
final tasks = await _repository.getTasks(
|
||||
companyId: companyId!,
|
||||
staffId: staffId,
|
||||
statuses: [TaskStatus.open, TaskStatus.inProgress],
|
||||
limit: 10,
|
||||
);
|
||||
if (isClosed) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: DashboardTaskListStatus.success,
|
||||
tasks: tasks,
|
||||
errorMessage: null,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (isClosed) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: DashboardTaskListStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
stopListening();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
part of 'dashboard_task_list_cubit.dart';
|
||||
|
||||
enum DashboardTaskListStatus { initial, loading, success, failure }
|
||||
|
||||
class DashboardTaskListState extends Equatable {
|
||||
final DashboardTaskListStatus status;
|
||||
final List<TaskModel> tasks;
|
||||
final String? errorMessage;
|
||||
|
||||
const DashboardTaskListState({
|
||||
this.status = DashboardTaskListStatus.initial,
|
||||
this.tasks = const [],
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
DashboardTaskListState copyWith({
|
||||
DashboardTaskListStatus? status,
|
||||
List<TaskModel>? tasks,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return DashboardTaskListState(
|
||||
status: status ?? this.status,
|
||||
tasks: tasks ?? this.tasks,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, tasks, errorMessage];
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/routes/routes.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flux/features/tasks/models/task_status.dart';
|
||||
|
||||
class DashboardTaskListCard extends StatelessWidget {
|
||||
const DashboardTaskListCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _DashboardTasksCardContent();
|
||||
}
|
||||
}
|
||||
|
||||
class _DashboardTasksCardContent extends StatelessWidget {
|
||||
const _DashboardTasksCardContent();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
const color =
|
||||
Colors.orange; // Colore arancione per distinguerla dai Ticket blu
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.pushNamed(
|
||||
Routes.tasks,
|
||||
), // Porta alla lista completa (TaskListScreen)
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- HEADER DELLA CARD ---
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.assignment_outlined, // Icona a tema ToDo
|
||||
color: color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"I Miei Task",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: context.primaryText,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
|
||||
Expanded(
|
||||
child: BlocBuilder<DashboardTaskListCubit, DashboardTaskListState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == DashboardTaskListStatus.loading ||
|
||||
state.status == DashboardTaskListStatus.initial) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state.status == DashboardTaskListStatus.failure) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"Errore di caricamento ${state.errorMessage}",
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.tasks.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"Nessun task in sospeso. Ottimo lavoro!",
|
||||
style: TextStyle(
|
||||
color: context.secondaryText,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: state.tasks.length,
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 1,
|
||||
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final task = state.tasks[index];
|
||||
|
||||
// Definisci il colore in base allo stato del task
|
||||
final statusColor = task.status == TaskStatus.inProgress
|
||||
? Colors.blue
|
||||
: Colors.grey.shade400;
|
||||
|
||||
// Formattiamo la data (o indichiamo se non c'è)
|
||||
final dueDateString = task.dueDate != null
|
||||
? "${task.dueDate!.day}/${task.dueDate!.month}"
|
||||
: "Nessuna";
|
||||
|
||||
// Controllo Ninja: Il task è già scaduto rispetto a oggi?
|
||||
final isOverdue =
|
||||
task.dueDate != null &&
|
||||
task.dueDate!.isBefore(DateTime.now());
|
||||
|
||||
return InkWell(
|
||||
onTap: () => context.pushNamed(
|
||||
Routes.taskForm,
|
||||
extra:
|
||||
task, // Passiamo direttamente il modello intero se il tuo router lo accetta!
|
||||
pathParameters: {'id': task.id ?? 'new'},
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: Text(
|
||||
task.title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryText,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
dueDateString,
|
||||
style: TextStyle(
|
||||
color: isOverdue
|
||||
? theme.colorScheme.error
|
||||
: context.secondaryText,
|
||||
fontSize: 12,
|
||||
fontWeight: isOverdue
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/features/operations/data/operations_repository.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
part '../../latest_store_operations/bloc/latest_store_operations_events.dart';
|
||||
part '../../latest_store_operations/bloc/latest_store_operations_state.dart';
|
||||
|
||||
class LatestStoreOperationsBloc
|
||||
extends Bloc<LatestStoreOperationsEvent, LatestStoreOperationsState> {
|
||||
final _repository = GetIt.I.get<OperationsRepository>();
|
||||
|
||||
LatestStoreOperationsBloc()
|
||||
: super(
|
||||
const LatestStoreOperationsState(
|
||||
status: LatestStoreOperationsStatus.initial,
|
||||
),
|
||||
) {
|
||||
on<InitLastStoreOperationsEvent>((event, emit) async {
|
||||
emit(state.copyWith(status: LatestStoreOperationsStatus.loading));
|
||||
try {
|
||||
// 1. Creiamo uno stream "intermedio" che idrata i dati
|
||||
final hydratedStream = _repository
|
||||
.getLastStoreOperationsStream(storeId: event.storeId, limit: 5)
|
||||
.asyncMap((List<OperationModel> rawOperations) async {
|
||||
// Questo gira ad ogni "scatto" dello stream di Supabase
|
||||
List<OperationModel> fullyHydratedOperations = [];
|
||||
|
||||
for (OperationModel operation in rawOperations) {
|
||||
// Peschiamo i dati completi (incluso il cliente)
|
||||
OperationModel fullOperation = await _repository
|
||||
.fetchOperationById(operation.id!);
|
||||
fullyHydratedOperations.add(fullOperation);
|
||||
}
|
||||
|
||||
// Passiamo la lista completa allo step successivo
|
||||
return fullyHydratedOperations;
|
||||
});
|
||||
|
||||
// 2. Ora passiamo lo stream idratato all'emit.forEach
|
||||
await emit.forEach(
|
||||
hydratedStream, // Usiamo lo stream modificato!
|
||||
onData: (List<OperationModel> fullyHydratedOperations) {
|
||||
// Qui ora è tutto sincrono e bellissimo
|
||||
return state.copyWith(
|
||||
operations: fullyHydratedOperations,
|
||||
status: LatestStoreOperationsStatus.success,
|
||||
);
|
||||
},
|
||||
onError: (error, stackTrace) => state.copyWith(
|
||||
status: LatestStoreOperationsStatus.failure,
|
||||
error: error.toString(),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: LatestStoreOperationsStatus.failure,
|
||||
error: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
part of 'latest_store_operations_bloc.dart';
|
||||
|
||||
sealed class LatestStoreOperationsEvent extends Equatable {
|
||||
const LatestStoreOperationsEvent();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class InitLastStoreOperationsEvent extends LatestStoreOperationsEvent {
|
||||
final String storeId;
|
||||
|
||||
const InitLastStoreOperationsEvent(this.storeId);
|
||||
|
||||
@override
|
||||
List<Object> get props => [storeId];
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
part of 'latest_store_operations_bloc.dart';
|
||||
|
||||
enum LatestStoreOperationsStatus { initial, loading, success, failure }
|
||||
|
||||
class LatestStoreOperationsState extends Equatable {
|
||||
final LatestStoreOperationsStatus status;
|
||||
final String? error;
|
||||
final List<OperationModel> operations;
|
||||
|
||||
const LatestStoreOperationsState({
|
||||
required this.status,
|
||||
this.error,
|
||||
this.operations = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, error, operations];
|
||||
|
||||
LatestStoreOperationsState copyWith({
|
||||
LatestStoreOperationsStatus? status,
|
||||
String? error,
|
||||
List<OperationModel>? operations,
|
||||
}) {
|
||||
return LatestStoreOperationsState(
|
||||
status: status ?? this.status,
|
||||
error: error,
|
||||
operations: operations ?? this.operations,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/routes/app_router.dart';
|
||||
import 'package:flux/core/routes/routes.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart';
|
||||
import 'package:flux/core/widgets/staff_selector_modal.dart';
|
||||
import 'package:flux/features/home/dashboard_note_list/blocs/dashboard_note_list_cubit.dart';
|
||||
import 'package:flux/features/home/dashboard_store_operation_list/bloc/dashboard_store_operation_list_cubit.dart';
|
||||
import 'package:flux/features/home/dashboard_store_ticket_list/blocs/dashboard_store_ticket_list_cubit.dart';
|
||||
import 'package:flux/features/home/dashboard_store_ticket_list/ui/dashboard_store_ticket_list_card.dart';
|
||||
import 'package:flux/features/home/dashboard_task_list/blocs/dashboard_task_list_cubit.dart';
|
||||
import 'package:flux/features/home/dashboard_task_list/ui/dashboard_task_list_card.dart';
|
||||
import 'package:flux/features/home/dashboard_store_operation_list/ui/latest_store_operations_card.dart';
|
||||
import 'package:flux/features/home/ui/quick_actions_widget.dart';
|
||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||
import 'package:flux/features/notes/data/notes_repository.dart';
|
||||
import 'package:flux/features/notes/models/note_model.dart';
|
||||
import 'package:flux/features/home/dashboard_note_list/ui/dashboard_note_list_card.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
late final AppLifecycleListener _lifecycleListener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (AppRouter.pendingRoute != null) {
|
||||
final destination = AppRouter.pendingRoute!;
|
||||
|
||||
// ⚠️ Svuota IMMEDIATAMENTE la variabile per evitare loop infiniti se si ruota lo schermo!
|
||||
AppRouter.pendingRoute = null;
|
||||
|
||||
// Spedisci l'utente al task!
|
||||
context.push(destination);
|
||||
}
|
||||
});
|
||||
|
||||
// Inizializziamo il sensore del ciclo di vita
|
||||
_lifecycleListener = AppLifecycleListener(
|
||||
onPause: () {
|
||||
// L'utente ha messo l'app in background (es. per rispondere a un messaggio su WhatsApp)
|
||||
// Chiudiamo i rubinetti per non sprecare risorse e prevenire crash
|
||||
_stopListeners();
|
||||
debugPrint('App in background: Stream sospesi.');
|
||||
},
|
||||
onResume: () {
|
||||
// L'utente è tornato sull'app!
|
||||
// Riappriamo i rubinetti, Supabase ricreerà una connessione fresca
|
||||
_startListeners();
|
||||
debugPrint('App in foreground: Stream riattivati.');
|
||||
},
|
||||
);
|
||||
|
||||
// Facciamo partire gli stream la primissima volta che la schermata si carica
|
||||
_startListeners();
|
||||
}
|
||||
|
||||
void _stopListeners() {
|
||||
context.read<DashboardStoreOperationListCubit>().stopListening();
|
||||
context.read<DashboardTaskListCubit>().stopListening();
|
||||
context.read<DashboardStoreTicketListCubit>().stopListening();
|
||||
context.read<DashboardNoteListCubit>().stopListening();
|
||||
}
|
||||
|
||||
void _startListeners() {
|
||||
context.read<DashboardStoreOperationListCubit>().startListening();
|
||||
context.read<DashboardTaskListCubit>().startListening();
|
||||
context.read<DashboardStoreTicketListCubit>().startListening();
|
||||
context.read<DashboardNoteListCubit>().startListening();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Pulizia fondamentale
|
||||
_lifecycleListener.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -59,34 +136,16 @@ class HomeScreen extends StatelessWidget {
|
||||
childAspectRatio: 1.3,
|
||||
),
|
||||
delegate: SliverChildListDelegate([
|
||||
DashboardStoreOperationListCard(),
|
||||
DashboardStoreTicketListCard(),
|
||||
_buildDashboardWidget(
|
||||
title: context.l10n.homeExpiringContracts,
|
||||
icon: Icons.assignment_late_outlined,
|
||||
color: Colors.orange,
|
||||
context: context,
|
||||
),
|
||||
_buildDashboardWidget(
|
||||
title: context.l10n.commonStickyNotes,
|
||||
icon: Icons.sticky_note_2_outlined,
|
||||
color: Colors.yellow.shade700,
|
||||
context: context,
|
||||
),
|
||||
_buildDashboardWidget(
|
||||
title: context.l10n.homeMyTasks,
|
||||
icon: Icons.check_box_outlined,
|
||||
color: Colors.green,
|
||||
context: context,
|
||||
),
|
||||
LatestStoreOperationsCard(),
|
||||
_buildDashboardWidget(
|
||||
title: context.l10n.homeLatestOperationTickets,
|
||||
icon: Icons.support_agent_outlined,
|
||||
color: Colors.purple,
|
||||
context: context,
|
||||
onTap: () => context.pushNamed(
|
||||
Routes.tickets,
|
||||
), // <-- Aggiunto!
|
||||
),
|
||||
DashboardNoteListCard(),
|
||||
DashboardTaskListCard(),
|
||||
]),
|
||||
),
|
||||
),
|
||||
@@ -103,9 +162,6 @@ class HomeScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// WIDGET BUILDERS
|
||||
// ==========================================
|
||||
|
||||
Widget _buildHeader(BuildContext context, ThemeData theme) {
|
||||
final user = context.watch<SessionCubit>().state.currentStaffMember;
|
||||
final currentStore = context.watch<SessionCubit>().state.currentStore;
|
||||
@@ -186,11 +242,13 @@ class HomeScreen extends StatelessWidget {
|
||||
icon: Icons.add,
|
||||
label: context.l10n.commonOperation,
|
||||
color: Colors.blue,
|
||||
onTap: () {
|
||||
// Entriamo nel form! Nessun parametro extra = Nuovo Servizio
|
||||
onTap: () async {
|
||||
StaffMemberModel? createdBy = await getStaffMember(context);
|
||||
if (createdBy == null || !context.mounted) return;
|
||||
context.pushNamed(
|
||||
Routes.operationForm,
|
||||
pathParameters: {'id': 'new'},
|
||||
extra: (createdBy: createdBy, operation: null),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -199,11 +257,13 @@ class HomeScreen extends StatelessWidget {
|
||||
icon: Icons.handyman,
|
||||
label: context.l10n.homeNewOperationTicket,
|
||||
color: Colors.redAccent,
|
||||
onTap: () {
|
||||
// Andiamo alla lista! (Da lì poi aggiungeremo il tasto "+" per il form)
|
||||
onTap: () async {
|
||||
StaffMemberModel? createdBy = await getStaffMember(context);
|
||||
if (createdBy == null || !context.mounted) return;
|
||||
context.pushNamed(
|
||||
Routes.ticketForm,
|
||||
pathParameters: {'id': 'new'},
|
||||
extra: (createdBy: createdBy, ticket: null),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -212,17 +272,35 @@ class HomeScreen extends StatelessWidget {
|
||||
icon: Icons.note_add,
|
||||
label: context.l10n.commonNote,
|
||||
color: Colors.amber,
|
||||
onTap: () {
|
||||
// TODO: Quando faremo il modale/pagina delle note
|
||||
onTap: () async {
|
||||
final companyId = context.read<SessionCubit>().state.company!.id!;
|
||||
final currentStaffId = context
|
||||
.read<SessionCubit>()
|
||||
.state
|
||||
.currentStaffMember!
|
||||
.id!;
|
||||
final emptyNote = NoteModel.empty(
|
||||
createdBy: currentStaffId,
|
||||
companyId: companyId,
|
||||
);
|
||||
final noteId = await GetIt.I.get<NotesRepository>().saveNote(
|
||||
emptyNote,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
context.pushNamed(
|
||||
Routes.noteForm,
|
||||
pathParameters: {'id': noteId},
|
||||
extra: emptyNote.copyWith(id: noteId),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
QuickActionButton(
|
||||
icon: Icons.task_alt,
|
||||
label: context.l10n.commonTask,
|
||||
color: Colors.teal,
|
||||
color: Colors.orange,
|
||||
onTap: () {
|
||||
// TODO: Quando faremo i task
|
||||
context.pushNamed(Routes.taskForm, pathParameters: {'id': 'new'});
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -383,6 +461,7 @@ class HomeScreen extends StatelessWidget {
|
||||
onTap: () {
|
||||
// Cambiamo il negozio nel SessionCubit!
|
||||
context.read<SessionCubit>().changeStore(store);
|
||||
context.read<StaffCubit>().loadStaffForStore(store.id!);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/brand_model.dart';
|
||||
@@ -14,7 +15,7 @@ class ProductRepository {
|
||||
Future<List<BrandModel>> getBrands() async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('brand')
|
||||
.from(Tables.brands)
|
||||
.select()
|
||||
.eq('company_id', _companyId)
|
||||
.eq('is_active', true)
|
||||
@@ -30,7 +31,7 @@ class ProductRepository {
|
||||
Future<BrandModel> upsertBrand(BrandModel brand) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('brand')
|
||||
.from(Tables.brands)
|
||||
.upsert(brand.toJson())
|
||||
.select()
|
||||
.single();
|
||||
@@ -47,7 +48,7 @@ class ProductRepository {
|
||||
Future<List<ModelModel>> getModelsByBrand(String brandId) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('model')
|
||||
.from(Tables.models)
|
||||
.select()
|
||||
.eq('brand_id', brandId)
|
||||
.eq('is_active', true)
|
||||
@@ -62,7 +63,7 @@ class ProductRepository {
|
||||
Future<List<ModelModel>> getModels() async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('model')
|
||||
.from(Tables.models)
|
||||
.select()
|
||||
.eq('is_active', true)
|
||||
.order('name');
|
||||
@@ -77,7 +78,7 @@ class ProductRepository {
|
||||
Future<ModelModel> upsertModel(ModelModel model) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('model')
|
||||
.from(Tables.models)
|
||||
.upsert(model.toJson())
|
||||
.select()
|
||||
.single();
|
||||
@@ -102,7 +103,7 @@ class ProductRepository {
|
||||
Future<List<ModelModel>> searchModels(String query) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('model')
|
||||
.from(Tables.models)
|
||||
.select()
|
||||
.ilike('name_with_brand', '%$query%') // Cerca ovunque nel nome
|
||||
.eq('is_active', true)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
||||
import 'package:flux/features/master_data/products/ui/product_dialogs.dart';
|
||||
@@ -63,9 +64,12 @@ class ModelsList extends StatelessWidget {
|
||||
: Icons.visibility_off_outlined,
|
||||
color: model.isActive ? context.accent : Colors.grey,
|
||||
),
|
||||
onPressed: () => context
|
||||
.read<ProductsCubit>()
|
||||
.toggleStatus('model', model.id!, model.isActive),
|
||||
onPressed: () =>
|
||||
context.read<ProductsCubit>().toggleStatus(
|
||||
Tables.models,
|
||||
model.id!,
|
||||
model.isActive,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/master_data/providers/data/provider_repository.dart';
|
||||
import 'package:flux/features/master_data/store/models/store_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../models/provider_model.dart';
|
||||
|
||||
class ProvidersState extends Equatable {
|
||||
final List<ProviderModel> allProviders;
|
||||
final List<String> associatedIds;
|
||||
// NUOVO CAMPO: Lista dei provider pronti per essere usati nel form pratiche
|
||||
final List<ProviderModel> activeProviders;
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
|
||||
const ProvidersState({
|
||||
this.allProviders = const [],
|
||||
this.associatedIds = const [],
|
||||
this.activeProviders = const [], // Inizializza
|
||||
this.isLoading = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
ProvidersState copyWith({
|
||||
List<ProviderModel>? allProviders,
|
||||
List<String>? associatedIds,
|
||||
List<ProviderModel>? activeProviders, // Aggiungi qui
|
||||
bool? isLoading,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return ProvidersState(
|
||||
allProviders: allProviders ?? this.allProviders,
|
||||
associatedIds: associatedIds ?? this.associatedIds,
|
||||
activeProviders: activeProviders ?? this.activeProviders, // Aggiungi qui
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage:
|
||||
errorMessage ??
|
||||
this.errorMessage, // Correzione bug: mancava "?? this.errorMessage" nel tuo originale
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
allProviders,
|
||||
associatedIds,
|
||||
activeProviders, // Aggiungi qui
|
||||
isLoading,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
|
||||
class ProvidersCubit extends Cubit<ProvidersState> {
|
||||
final ProviderRepository _repository = GetIt.I<ProviderRepository>();
|
||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||
|
||||
ProvidersCubit() : super(const ProvidersState());
|
||||
|
||||
// Carica i provider della company e quelli associati a uno store specifico
|
||||
Future<void> loadProviders({StoreModel? store}) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final all = await _repository.fetchAllCompanyProviders(
|
||||
_sessionCubit.state.company!.id!,
|
||||
);
|
||||
List<String> associated = [];
|
||||
|
||||
if (store != null) {
|
||||
associated = await _repository.fetchAssociatedProviderIds(store.id!);
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
allProviders: all,
|
||||
associatedIds: associated,
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadActiveProvidersForStore(String storeId) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final activeList = await _repository.fetchActiveProvidersForStore(
|
||||
storeId,
|
||||
);
|
||||
emit(state.copyWith(activeProviders: activeList, isLoading: false));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: "Errore caricamento gestori: $e",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggiunge o rimuove l'associazione con lo store
|
||||
Future<void> toggleProviderAssociation({
|
||||
required String providerId,
|
||||
required String storeId,
|
||||
required bool isCurrentlyAssociated,
|
||||
}) async {
|
||||
try {
|
||||
if (isCurrentlyAssociated) {
|
||||
await _repository.disassociateProviderFromStore(
|
||||
providerId: providerId,
|
||||
storeId: storeId,
|
||||
);
|
||||
// Aggiorniamo lo stato locale rimuovendo l'ID
|
||||
final newIds = List<String>.from(state.associatedIds)
|
||||
..remove(providerId);
|
||||
emit(state.copyWith(associatedIds: newIds));
|
||||
} else {
|
||||
await _repository.associateProviderToStore(
|
||||
providerId: providerId,
|
||||
storeId: storeId,
|
||||
);
|
||||
// Aggiorniamo lo stato locale aggiungendo l'ID
|
||||
final newIds = List<String>.from(state.associatedIds)..add(providerId);
|
||||
emit(state.copyWith(associatedIds: newIds));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(state.copyWith(errorMessage: "Errore durante l'aggiornamento: $e"));
|
||||
}
|
||||
}
|
||||
|
||||
// Salvataggio/Update anagrafica (nuovo o modifica)
|
||||
Future<void> saveProvider(
|
||||
ProviderModel provider,
|
||||
List<String> selectedStoreIds,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
// Assicuriamoci di settare la companyId prima di salvare
|
||||
provider = provider.copyWith(companyId: _sessionCubit.state.company!.id);
|
||||
try {
|
||||
// 1. Salviamo l'anagrafica (upsert)
|
||||
// Se è un nuovo provider, l'ID potrebbe essere generato qui dal DB
|
||||
// Quindi carichiamo il risultato del salvataggio per avere l'ID
|
||||
final response = await _repository.saveProvider(provider);
|
||||
|
||||
// Assumiamo che il saveProvider restituisca l'oggetto salvato con l'ID
|
||||
final pId = provider.id ?? response.id;
|
||||
|
||||
// 2. Sincronizziamo i negozi
|
||||
await _repository.syncProviderStores(pId!, selectedStoreIds);
|
||||
|
||||
// 3. Ricarichiamo tutto
|
||||
await loadProviders();
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveProviderWithStores(
|
||||
ProviderModel provider,
|
||||
List<String> storeIds,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
// 1. Salva l'anagrafica provider
|
||||
await _repository.saveProvider(provider);
|
||||
|
||||
// 2. Sincronizza i negozi (la via più semplice è cancellare e reinserire
|
||||
// o fare un confronto tra i presenti e i nuovi)
|
||||
await _repository.syncProviderStores(provider.id!, storeIds);
|
||||
|
||||
await loadProviders();
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart'; // Per estrarre gli store
|
||||
import '../models/provider_model.dart';
|
||||
import '../models/provider_role.dart';
|
||||
import '../models/provider_location_model.dart';
|
||||
import '../data/provider_repository.dart';
|
||||
part 'provider_form_state.dart';
|
||||
|
||||
class ProviderFormCubit extends Cubit<ProviderFormState> {
|
||||
final ProviderRepository _repository = GetIt.I.get<ProviderRepository>();
|
||||
final _client = Supabase.instance.client; // Lo usiamo al volo per gli store
|
||||
|
||||
ProviderFormCubit()
|
||||
: super(
|
||||
ProviderFormState(
|
||||
provider: ProviderModel.empty(
|
||||
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// --- INIZIALIZZAZIONE ---
|
||||
Future<void> initForm({
|
||||
required String companyId,
|
||||
ProviderModel? existingProvider,
|
||||
}) async {
|
||||
emit(state.copyWith(status: ProviderFormStatus.loading));
|
||||
|
||||
try {
|
||||
// 1. Scarichiamo tutti i negozi dell'azienda
|
||||
final storesResponse = await _client
|
||||
.from(Tables.stores)
|
||||
.select('id, name')
|
||||
.eq('company_id', companyId);
|
||||
|
||||
// 2. Se stiamo modificando, carichiamo gli store collegati
|
||||
List<String> linkedStoreIds = [];
|
||||
if (existingProvider != null && existingProvider.id != null) {
|
||||
// ... (Vecchio codice di recupero)
|
||||
final links = await _client
|
||||
.from(Tables.providersInStores)
|
||||
.select('store_id')
|
||||
.eq('provider_id', existingProvider.id!);
|
||||
linkedStoreIds = (links as List)
|
||||
.map((l) => l['store_id'] as String)
|
||||
.toList();
|
||||
} else {
|
||||
// --- IL TOCCO NINJA: AUTO-SELEZIONE ---
|
||||
// Se stiamo creando un nuovo fornitore e c'è 1 solo negozio in tutto il DB, accendilo!
|
||||
if ((storesResponse as List).length == 1) {
|
||||
linkedStoreIds.add(storesResponse.first['id'] as String);
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ProviderFormStatus.initial,
|
||||
provider:
|
||||
existingProvider ?? ProviderModel.empty(companyId: companyId),
|
||||
availableStores: storesResponse as List<dynamic>,
|
||||
selectedStoreIds: linkedStoreIds,
|
||||
localLocations: existingProvider?.locations ?? [],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ProviderFormStatus.failure,
|
||||
errorMessage: 'Errore durante l\'inizializzazione: $e',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- AGGIORNAMENTO CAMPI ---
|
||||
void updateFields({
|
||||
String? name,
|
||||
String? businessName,
|
||||
String? vatNumber,
|
||||
String? fiscalCode,
|
||||
String? sdiCode,
|
||||
String? emailPec,
|
||||
String? Function()? colorHex,
|
||||
}) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
provider: state.provider.copyWith(
|
||||
name: name,
|
||||
businessName: businessName,
|
||||
vatNumber: vatNumber,
|
||||
fiscalCode: fiscalCode,
|
||||
sdiCode: sdiCode,
|
||||
emailPec: emailPec,
|
||||
colorHex: colorHex,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- GESTIONE RUOLI (CHIPS) ---
|
||||
void toggleRole(ProviderRole role) {
|
||||
final currentRoles = List<ProviderRole>.from(state.provider.roles);
|
||||
if (currentRoles.contains(role)) {
|
||||
currentRoles.remove(role);
|
||||
} else {
|
||||
currentRoles.add(role);
|
||||
}
|
||||
emit(
|
||||
state.copyWith(provider: state.provider.copyWith(roles: currentRoles)),
|
||||
);
|
||||
}
|
||||
|
||||
// --- GESTIONE NEGOZI ABILITATI (CHECKBOX) ---
|
||||
void toggleStore(String storeId) {
|
||||
final currentStoreIds = List<String>.from(state.selectedStoreIds);
|
||||
if (currentStoreIds.contains(storeId)) {
|
||||
currentStoreIds.remove(storeId);
|
||||
} else {
|
||||
currentStoreIds.add(storeId);
|
||||
}
|
||||
emit(state.copyWith(selectedStoreIds: currentStoreIds));
|
||||
}
|
||||
|
||||
Future<void> addLocationLocal(ProviderLocationModel location) async {
|
||||
final currentLocations = List<ProviderLocationModel>.from(
|
||||
state.localLocations,
|
||||
);
|
||||
currentLocations.add(location);
|
||||
emit(state.copyWith(localLocations: currentLocations));
|
||||
}
|
||||
|
||||
void removeLocationLocal(int index) {
|
||||
final currentLocations = List<ProviderLocationModel>.from(
|
||||
state.localLocations,
|
||||
);
|
||||
if (index >= 0 && index < currentLocations.length) {
|
||||
currentLocations.removeAt(index);
|
||||
emit(state.copyWith(localLocations: currentLocations));
|
||||
}
|
||||
}
|
||||
|
||||
// --- SALVATAGGIO FINALE ---
|
||||
Future<void> save() async {
|
||||
// Sicurezza di base
|
||||
if (state.provider.name.trim().isEmpty) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ProviderFormStatus.failure,
|
||||
errorMessage: 'Il nome è obbligatorio',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(status: ProviderFormStatus.loading));
|
||||
|
||||
try {
|
||||
// Passiamo provider e storeId al repository che farà la magia
|
||||
final savedProvider = await _repository.saveProvider(
|
||||
state.provider,
|
||||
state.selectedStoreIds,
|
||||
);
|
||||
|
||||
if (state.localLocations.isNotEmpty) {
|
||||
for (var loc in state.localLocations) {
|
||||
final locToSave = loc.copyWith(
|
||||
providerId: savedProvider.id!,
|
||||
companyId: savedProvider.companyId,
|
||||
);
|
||||
await _repository.saveLocation(locToSave);
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ProviderFormStatus.success,
|
||||
provider: savedProvider,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ProviderFormStatus.failure,
|
||||
errorMessage: 'Errore di salvataggio: $e',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||